diff --git a/README.md b/README.md index c75e855..32fd15f 100644 --- a/README.md +++ b/README.md @@ -140,14 +140,20 @@ builder.Services.AddTransientTraxRoute(); Or use `AddServiceTrainBus` (from [Trax.Mediator](https://www.nuget.org/packages/Trax.Mediator/)) to auto-register all trains in an assembly. -## Related Packages - -| Package | Purpose | -|---------|---------| -| [Trax.Core](https://www.nuget.org/packages/Trax.Core/) | The locomotive — `Train`, steps, railway programming | -| [Trax.Mediator](https://www.nuget.org/packages/Trax.Mediator/) | Dispatch station — route cargo to the right train via `TrainBus` | -| [Trax.Scheduler](https://www.nuget.org/packages/Trax.Scheduler/) | Timetables — recurring trains with retries and dead-lettering | -| [Trax.Dashboard](https://www.nuget.org/packages/Trax.Dashboard/) | Control room — monitor every journey on the network | +## Part of Trax + +Trax is a layered framework — each package builds on the one below it. Stop at whatever layer solves your problem. + +``` +Trax.Core pipelines, steps, railway error propagation +└→ Trax.Effect ← you are here + └→ Trax.Mediator + decoupled dispatch via TrainBus + └→ Trax.Scheduler + cron schedules, retries, dead-letter queues + └→ Trax.Api + GraphQL API for remote access + └→ Trax.Dashboard + Blazor monitoring UI +``` + +**Next layer:** When you need decoupled dispatch (callers don't know which train handles a request), add [Trax.Mediator](https://www.nuget.org/packages/Trax.Mediator/). Full documentation: [traxsharp.github.io/Trax.Docs](https://traxsharp.github.io/Trax.Docs) diff --git a/src/Trax.Effect/Attributes/TraxBroadcastAttribute.cs b/src/Trax.Effect/Attributes/TraxBroadcastAttribute.cs new file mode 100644 index 0000000..ac600d8 --- /dev/null +++ b/src/Trax.Effect/Attributes/TraxBroadcastAttribute.cs @@ -0,0 +1,13 @@ +namespace Trax.Effect.Attributes; + +/// +/// Opts a train into broadcasting lifecycle events to GraphQL subscribers. +/// Only trains decorated with this attribute will have their lifecycle transitions +/// (started, completed, failed, cancelled) published to WebSocket subscribers. +/// +/// +/// Place this attribute on the concrete train class. Trains without this attribute +/// will not emit subscription events, even if lifecycle hooks are registered. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] +public class TraxBroadcastAttribute : Attribute { } diff --git a/src/Trax.Effect/Attributes/TraxMutationAttribute.cs b/src/Trax.Effect/Attributes/TraxMutationAttribute.cs new file mode 100644 index 0000000..f43e941 --- /dev/null +++ b/src/Trax.Effect/Attributes/TraxMutationAttribute.cs @@ -0,0 +1,62 @@ +namespace Trax.Effect.Attributes; + +/// +/// Exposes a train as typed GraphQL mutation field(s) under dispatch. +/// Generates run{Name} and/or queue{Name} mutations based on +/// the property. +/// +/// +/// Place this attribute on the concrete train class. The generated field names are derived +/// by stripping the I prefix and Train suffix from the service type name, +/// then prepending run or queue +/// (e.g. IBanPlayerTrainrunBanPlayer / queueBanPlayer). +/// +/// Trains without this attribute (or ) are not exposed +/// as GraphQL endpoints. A train cannot have both attributes. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] +public class TraxMutationAttribute : Attribute +{ + /// + /// Overrides the auto-derived GraphQL field name. + /// When null, the name is derived by stripping the "I" prefix and "Train" suffix + /// from the service type name (e.g. IBanPlayerTrainBanPlayer). + /// This produces runBanPlayer and/or queueBanPlayer. + /// + public string? Name { get; init; } + + /// + /// A human-readable description that appears in the GraphQL schema documentation. + /// + public string? Description { get; init; } + + /// + /// Marks the generated mutations as deprecated in the GraphQL schema. + /// Clients see a deprecation warning during introspection. + /// + public string? DeprecationReason { get; init; } + + /// + /// Controls which mutation operations are generated. + /// Defaults to (synchronous execution only). + /// Set to for scheduler dispatch only, + /// or for both. + /// + public GraphQLOperation Operations { get; init; } = GraphQLOperation.Run; +} + +/// +/// Controls which typed mutation operations are generated for a train. +/// +[Flags] +public enum GraphQLOperation +{ + /// Generate only the run{Name} mutation (synchronous execution). + Run = 1, + + /// Generate only the queue{Name} mutation (scheduler dispatch). + Queue = 2, + + /// Generate both run{Name} and queue{Name} mutations. + RunAndQueue = Run | Queue, +} diff --git a/src/Trax.Effect/Attributes/TraxQueryAttribute.cs b/src/Trax.Effect/Attributes/TraxQueryAttribute.cs new file mode 100644 index 0000000..1965383 --- /dev/null +++ b/src/Trax.Effect/Attributes/TraxQueryAttribute.cs @@ -0,0 +1,35 @@ +namespace Trax.Effect.Attributes; + +/// +/// Exposes a train as a typed GraphQL query field under discover. +/// Query trains always execute synchronously via RunAsync on the current server. +/// +/// +/// Place this attribute on the concrete train class. The generated field name is derived +/// by stripping the I prefix and Train suffix from the service type name +/// (e.g. ILookupPlayerTrainlookupPlayer), or overridden via . +/// +/// Trains without this attribute (or ) are not exposed +/// as GraphQL endpoints. A train cannot have both attributes. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] +public class TraxQueryAttribute : Attribute +{ + /// + /// Overrides the auto-derived GraphQL field name. + /// When null, the name is derived by stripping the "I" prefix and "Train" suffix + /// from the service type name (e.g. ILookupPlayerTrainlookupPlayer). + /// + public string? Name { get; init; } + + /// + /// A human-readable description that appears in the GraphQL schema documentation. + /// + public string? Description { get; init; } + + /// + /// Marks the generated field as deprecated in the GraphQL schema. + /// Clients see a deprecation warning during introspection. + /// + public string? DeprecationReason { get; init; } +} diff --git a/src/Trax.Effect/Extensions/ServiceExtensions.cs b/src/Trax.Effect/Extensions/ServiceExtensions.cs index 718e805..3c038f9 100644 --- a/src/Trax.Effect/Extensions/ServiceExtensions.cs +++ b/src/Trax.Effect/Extensions/ServiceExtensions.cs @@ -6,8 +6,10 @@ using Trax.Effect.Services.EffectProviderFactory; using Trax.Effect.Services.EffectRegistry; using Trax.Effect.Services.EffectRunner; +using Trax.Effect.Services.LifecycleHookRunner; using Trax.Effect.Services.StepEffectProviderFactory; using Trax.Effect.Services.StepEffectRunner; +using Trax.Effect.Services.TrainLifecycleHookFactory; namespace Trax.Effect.Extensions; @@ -29,7 +31,8 @@ public static IServiceCollection AddTraxEffects( .AddSingleton(registry) .AddSingleton(configuration) .AddTransient() - .AddTransient(); + .AddTransient() + .AddTransient(); } private static TraxEffectConfiguration BuildConfiguration( @@ -236,6 +239,66 @@ public static TraxEffectConfigurationBuilder AddStepEffect( + this TraxEffectConfigurationBuilder builder, + TLifecycleHookFactory factory, + bool toggleable = true + ) + where TILifecycleHookFactory : class, ITrainLifecycleHookFactory + where TLifecycleHookFactory : class, TILifecycleHookFactory + { + builder + .ServiceCollection.AddSingleton(factory) + .AddSingleton(sp => + sp.GetRequiredService() + ) + .AddSingleton(sp => + sp.GetRequiredService() + ); + + builder.EffectRegistry?.Register(typeof(TLifecycleHookFactory), toggleable: toggleable); + + return builder; + } + + public static TraxEffectConfigurationBuilder AddLifecycleHook( + this TraxEffectConfigurationBuilder builder, + bool toggleable = true + ) + where TLifecycleHookFactory : class, ITrainLifecycleHookFactory + { + builder + .ServiceCollection.AddSingleton() + .AddSingleton(sp => + sp.GetRequiredService() + ); + + builder.EffectRegistry?.Register(typeof(TLifecycleHookFactory), toggleable: toggleable); + + return builder; + } + + public static TraxEffectConfigurationBuilder AddLifecycleHook( + this TraxEffectConfigurationBuilder builder, + TLifecycleHookFactory factory, + bool toggleable = true + ) + where TLifecycleHookFactory : class, ITrainLifecycleHookFactory + { + builder.ServiceCollection.AddSingleton(factory); + + builder.EffectRegistry?.Register(typeof(TLifecycleHookFactory), toggleable: toggleable); + + return builder; + } + + #endregion + #region StepInjection public static IServiceCollection AddScopedTraxStep( diff --git a/src/Trax.Effect/Services/LifecycleHookRunner/ILifecycleHookRunner.cs b/src/Trax.Effect/Services/LifecycleHookRunner/ILifecycleHookRunner.cs new file mode 100644 index 0000000..f4a78ed --- /dev/null +++ b/src/Trax.Effect/Services/LifecycleHookRunner/ILifecycleHookRunner.cs @@ -0,0 +1,15 @@ +using Trax.Effect.Models.Metadata; + +namespace Trax.Effect.Services.LifecycleHookRunner; + +/// +/// Coordinates multiple implementations, +/// broadcasting train lifecycle events to all registered hooks. +/// +public interface ILifecycleHookRunner : IDisposable +{ + Task OnStarted(Metadata metadata, CancellationToken ct); + Task OnCompleted(Metadata metadata, CancellationToken ct); + Task OnFailed(Metadata metadata, Exception exception, CancellationToken ct); + Task OnCancelled(Metadata metadata, CancellationToken ct); +} diff --git a/src/Trax.Effect/Services/LifecycleHookRunner/LifecycleHookRunner.cs b/src/Trax.Effect/Services/LifecycleHookRunner/LifecycleHookRunner.cs new file mode 100644 index 0000000..5bb0dae --- /dev/null +++ b/src/Trax.Effect/Services/LifecycleHookRunner/LifecycleHookRunner.cs @@ -0,0 +1,135 @@ +using Microsoft.Extensions.Logging; +using Trax.Effect.Models.Metadata; +using Trax.Effect.Services.EffectRegistry; +using Trax.Effect.Services.TrainLifecycleHook; +using Trax.Effect.Services.TrainLifecycleHookFactory; + +namespace Trax.Effect.Services.LifecycleHookRunner; + +/// +/// Composite that broadcasts train lifecycle events to all registered hooks. +/// Exceptions in individual hooks are caught and logged — a failing hook never causes +/// the train itself to fail. +/// +public class LifecycleHookRunner : ILifecycleHookRunner +{ + private readonly List _hooks; + private readonly ILogger? _logger; + + public LifecycleHookRunner( + IEnumerable hookFactories, + IEffectRegistry effectRegistry, + ILogger? logger = null + ) + { + _logger = logger; + _hooks = hookFactories + .Where(factory => effectRegistry.IsEnabled(factory.GetType())) + .Select(factory => factory.Create()) + .ToList(); + } + + public async Task OnStarted(Metadata metadata, CancellationToken ct) + { + foreach (var hook in _hooks) + { + try + { + await hook.OnStarted(metadata, ct); + } + catch (Exception ex) + { + _logger?.LogError( + ex, + "Lifecycle hook ({HookType}) threw on OnStarted for train ({TrainName}).", + hook.GetType().Name, + metadata.Name + ); + } + } + } + + public async Task OnCompleted(Metadata metadata, CancellationToken ct) + { + foreach (var hook in _hooks) + { + try + { + await hook.OnCompleted(metadata, ct); + } + catch (Exception ex) + { + _logger?.LogError( + ex, + "Lifecycle hook ({HookType}) threw on OnCompleted for train ({TrainName}).", + hook.GetType().Name, + metadata.Name + ); + } + } + } + + public async Task OnFailed(Metadata metadata, Exception exception, CancellationToken ct) + { + foreach (var hook in _hooks) + { + try + { + await hook.OnFailed(metadata, exception, ct); + } + catch (Exception ex) + { + _logger?.LogError( + ex, + "Lifecycle hook ({HookType}) threw on OnFailed for train ({TrainName}).", + hook.GetType().Name, + metadata.Name + ); + } + } + } + + public async Task OnCancelled(Metadata metadata, CancellationToken ct) + { + foreach (var hook in _hooks) + { + try + { + await hook.OnCancelled(metadata, ct); + } + catch (Exception ex) + { + _logger?.LogError( + ex, + "Lifecycle hook ({HookType}) threw on OnCancelled for train ({TrainName}).", + hook.GetType().Name, + metadata.Name + ); + } + } + } + + public void Dispose() + { + foreach (var hook in _hooks) + { + if (hook is IDisposable disposable) + { + try + { + disposable.Dispose(); + } + catch (Exception ex) + { + _logger?.LogError( + ex, + "Failed to dispose lifecycle hook ({HookType}).", + hook.GetType().Name + ); + } + } + } + + _hooks.Clear(); + } +} diff --git a/src/Trax.Effect/Services/ServiceTrain/ServiceTrain.cs b/src/Trax.Effect/Services/ServiceTrain/ServiceTrain.cs index 1844c83..c582240 100644 --- a/src/Trax.Effect/Services/ServiceTrain/ServiceTrain.cs +++ b/src/Trax.Effect/Services/ServiceTrain/ServiceTrain.cs @@ -11,6 +11,7 @@ using Trax.Effect.Models.Metadata; using Trax.Effect.Models.Metadata.DTOs; using Trax.Effect.Services.EffectRunner; +using Trax.Effect.Services.LifecycleHookRunner; using Trax.Effect.Services.StepEffectRunner; namespace Trax.Effect.Services.ServiceTrain; @@ -48,6 +49,10 @@ public abstract class ServiceTrain : Train, IServiceTrain< [JsonIgnore] public IStepEffectRunner? StepEffectRunner { get; set; } + [Inject] + [JsonIgnore] + public ILifecycleHookRunner? LifecycleHookRunner { get; set; } + /// /// Logger specific to this train type, used for recording diagnostic information. /// @@ -81,6 +86,7 @@ public override async Task Run(TIn input, CancellationToken cancellationTo EffectRunner.AssertLoaded(); StepEffectRunner.AssertLoaded(); + LifecycleHookRunner.AssertLoaded(); ServiceProvider.AssertLoaded(); if (Metadata == null) @@ -89,6 +95,8 @@ public override async Task Run(TIn input, CancellationToken cancellationTo Metadata.AssertLoaded(); await EffectRunner.SaveChanges(CancellationToken); + await LifecycleHookRunner.OnStarted(Metadata, CancellationToken); + try { Logger?.LogTrace("Running Train: ({TrainName})", TrainName); @@ -105,6 +113,12 @@ public override async Task Run(TIn input, CancellationToken cancellationTo ); await this.FinishServiceTrain(result); await EffectRunner.SaveChanges(CancellationToken); + + if (exception is OperationCanceledException) + await LifecycleHookRunner.OnCancelled(Metadata, CancellationToken); + else + await LifecycleHookRunner.OnFailed(Metadata, exception, CancellationToken); + exception.Rethrow(); } @@ -116,6 +130,8 @@ public override async Task Run(TIn input, CancellationToken cancellationTo await this.FinishServiceTrain(result); await EffectRunner.SaveChanges(CancellationToken); + await LifecycleHookRunner.OnCompleted(Metadata, CancellationToken); + return output; } catch (Exception e) @@ -129,6 +145,11 @@ public override async Task Run(TIn input, CancellationToken cancellationTo await this.FinishServiceTrain(e); await EffectRunner.SaveChanges(CancellationToken); + if (e is OperationCanceledException) + await LifecycleHookRunner.OnCancelled(Metadata, CancellationToken); + else + await LifecycleHookRunner.OnFailed(Metadata, e, CancellationToken); + throw; } } @@ -178,6 +199,7 @@ public void Dispose() EffectRunner?.Dispose(); StepEffectRunner?.Dispose(); + LifecycleHookRunner?.Dispose(); Metadata?.Dispose(); Logger = null; diff --git a/src/Trax.Effect/Services/TrainLifecycleHook/ITrainLifecycleHook.cs b/src/Trax.Effect/Services/TrainLifecycleHook/ITrainLifecycleHook.cs new file mode 100644 index 0000000..4703820 --- /dev/null +++ b/src/Trax.Effect/Services/TrainLifecycleHook/ITrainLifecycleHook.cs @@ -0,0 +1,17 @@ +using Trax.Effect.Models.Metadata; + +namespace Trax.Effect.Services.TrainLifecycleHook; + +/// +/// Hook interface for reacting to train state transitions. +/// Implement this to create custom side effects (e.g., metrics, alerts, subscriptions). +/// Default interface methods allow implementations to override only the events they care about. +/// +public interface ITrainLifecycleHook +{ + Task OnStarted(Metadata metadata, CancellationToken ct) => Task.CompletedTask; + Task OnCompleted(Metadata metadata, CancellationToken ct) => Task.CompletedTask; + Task OnFailed(Metadata metadata, Exception exception, CancellationToken ct) => + Task.CompletedTask; + Task OnCancelled(Metadata metadata, CancellationToken ct) => Task.CompletedTask; +} diff --git a/src/Trax.Effect/Services/TrainLifecycleHookFactory/ITrainLifecycleHookFactory.cs b/src/Trax.Effect/Services/TrainLifecycleHookFactory/ITrainLifecycleHookFactory.cs new file mode 100644 index 0000000..b054a14 --- /dev/null +++ b/src/Trax.Effect/Services/TrainLifecycleHookFactory/ITrainLifecycleHookFactory.cs @@ -0,0 +1,12 @@ +using Trax.Effect.Services.TrainLifecycleHook; + +namespace Trax.Effect.Services.TrainLifecycleHookFactory; + +/// +/// Factory for creating instances. +/// Registered via AddLifecycleHook<TFactory>() on the effect configuration builder. +/// +public interface ITrainLifecycleHookFactory +{ + ITrainLifecycleHook Create(); +} diff --git a/tests/Trax.Effect.Tests.Integration/UnitTests/Services/AddLifecycleHookTests.cs b/tests/Trax.Effect.Tests.Integration/UnitTests/Services/AddLifecycleHookTests.cs new file mode 100644 index 0000000..2c7776a --- /dev/null +++ b/tests/Trax.Effect.Tests.Integration/UnitTests/Services/AddLifecycleHookTests.cs @@ -0,0 +1,155 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Trax.Effect.Extensions; +using Trax.Effect.Services.EffectRegistry; +using Trax.Effect.Services.LifecycleHookRunner; +using Trax.Effect.Services.TrainLifecycleHook; +using Trax.Effect.Services.TrainLifecycleHookFactory; + +namespace Trax.Effect.Tests.Integration.UnitTests.Services; + +[TestFixture] +public class AddLifecycleHookTests +{ + #region Type-Only Registration + + [Test] + public void AddLifecycleHook_TypeOnly_RegistersFactoryAsSingleton() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddTraxEffects(options => options.AddLifecycleHook()); + using var provider = services.BuildServiceProvider(); + + var factories = provider.GetServices().ToList(); + + factories.Should().ContainSingle(f => f is StubHookFactory); + } + + [Test] + public void AddLifecycleHook_TypeOnly_FactoryResolvableByConcreteType() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddTraxEffects(options => options.AddLifecycleHook()); + using var provider = services.BuildServiceProvider(); + + var factory = provider.GetService(); + + factory.Should().NotBeNull(); + } + + [Test] + public void AddLifecycleHook_TypeOnly_RegistersInEffectRegistry() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddTraxEffects(options => options.AddLifecycleHook()); + using var provider = services.BuildServiceProvider(); + + var registry = provider.GetRequiredService(); + + registry.IsEnabled(typeof(StubHookFactory)).Should().BeTrue(); + registry.IsToggleable(typeof(StubHookFactory)).Should().BeTrue(); + } + + [Test] + public void AddLifecycleHook_NonToggleable_RegisteredAsNonToggleable() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddTraxEffects(options => + options.AddLifecycleHook(toggleable: false) + ); + using var provider = services.BuildServiceProvider(); + + var registry = provider.GetRequiredService(); + + registry.IsToggleable(typeof(StubHookFactory)).Should().BeFalse(); + } + + #endregion + + #region Instance Registration + + [Test] + public void AddLifecycleHook_Instance_RegistersProvidedFactory() + { + var instance = new StubHookFactory(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddTraxEffects(options => options.AddLifecycleHook(instance)); + using var provider = services.BuildServiceProvider(); + + var factories = provider.GetServices().ToList(); + + factories.Should().ContainSingle(f => ReferenceEquals(f, instance)); + } + + #endregion + + #region LifecycleHookRunner Resolution + + [Test] + public void LifecycleHookRunner_ResolvedFromDI_IncludesRegisteredHooks() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddTraxEffects(options => options.AddLifecycleHook()); + using var provider = services.BuildServiceProvider(); + + var runner = provider.GetService(); + + runner.Should().NotBeNull(); + } + + [Test] + public void LifecycleHookRunner_NoHooksRegistered_StillResolvable() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddTraxEffects(_ => { }); + using var provider = services.BuildServiceProvider(); + + var runner = provider.GetService(); + + runner.Should().NotBeNull(); + } + + #endregion + + #region Multiple Hooks + + [Test] + public void AddLifecycleHook_Multiple_AllRegistered() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddTraxEffects(options => + options.AddLifecycleHook().AddLifecycleHook() + ); + using var provider = services.BuildServiceProvider(); + + var factories = provider.GetServices().ToList(); + + factories.Should().HaveCount(2); + } + + #endregion + + #region Test Stubs + + private class StubHook : ITrainLifecycleHook { } + + private class StubHookFactory : ITrainLifecycleHookFactory + { + public ITrainLifecycleHook Create() => new StubHook(); + } + + private class AnotherStubHookFactory : ITrainLifecycleHookFactory + { + public ITrainLifecycleHook Create() => new StubHook(); + } + + #endregion +} diff --git a/tests/Trax.Effect.Tests.Integration/UnitTests/Services/LifecycleHookRunnerTests.cs b/tests/Trax.Effect.Tests.Integration/UnitTests/Services/LifecycleHookRunnerTests.cs new file mode 100644 index 0000000..a0727dc --- /dev/null +++ b/tests/Trax.Effect.Tests.Integration/UnitTests/Services/LifecycleHookRunnerTests.cs @@ -0,0 +1,570 @@ +using FluentAssertions; +using Trax.Effect.Models.Metadata; +using Trax.Effect.Services.EffectRegistry; +using Trax.Effect.Services.LifecycleHookRunner; +using Trax.Effect.Services.TrainLifecycleHook; +using Trax.Effect.Services.TrainLifecycleHookFactory; + +namespace Trax.Effect.Tests.Integration.UnitTests.Services; + +[TestFixture] +public class LifecycleHookRunnerTests +{ + private EffectRegistry _registry; + + [SetUp] + public void SetUp() + { + _registry = new EffectRegistry(); + } + + #region Construction & Filtering + + [Test] + public void Constructor_EnabledFactory_CreatesHook() + { + var factory = new TrackingHookFactory(); + _registry.Register(typeof(TrackingHookFactory), enabled: true); + + using var runner = new LifecycleHookRunner([factory], _registry); + + factory.CreateCalled.Should().BeTrue(); + } + + [Test] + public void Constructor_DisabledFactory_SkipsCreate() + { + var factory = new TrackingHookFactory(); + _registry.Register(typeof(TrackingHookFactory), enabled: false); + + using var runner = new LifecycleHookRunner([factory], _registry); + + factory.CreateCalled.Should().BeFalse(); + } + + [Test] + public void Constructor_UntrackedFactory_CreatesHook() + { + var factory = new TrackingHookFactory(); + // Not registered in registry — infrastructure effects default to enabled + + using var runner = new LifecycleHookRunner([factory], _registry); + + factory.CreateCalled.Should().BeTrue(); + } + + [Test] + public void Constructor_MixedFactories_OnlyCreatesEnabled() + { + var enabled = new TrackingHookFactory(); + var disabled = new DisabledTrackingHookFactory(); + + _registry.Register(typeof(TrackingHookFactory), enabled: true); + _registry.Register(typeof(DisabledTrackingHookFactory), enabled: false); + + using var runner = new LifecycleHookRunner([enabled, disabled], _registry); + + enabled.CreateCalled.Should().BeTrue(); + disabled.CreateCalled.Should().BeFalse(); + } + + [Test] + public void Constructor_NoFactories_DoesNotThrow() + { + var act = () => new LifecycleHookRunner([], _registry); + + act.Should().NotThrow(); + } + + #endregion + + #region OnStarted + + [Test] + public async Task OnStarted_BroadcastsToAllHooks() + { + var hook1 = new RecordingHook(); + var hook2 = new RecordingHook(); + var factory1 = new DirectHookFactory(hook1); + var factory2 = new DirectHookFactory2(hook2); + + _registry.Register(typeof(DirectHookFactory), enabled: true); + _registry.Register(typeof(DirectHookFactory2), enabled: true); + + using var runner = new LifecycleHookRunner([factory1, factory2], _registry); + var metadata = CreateTestMetadata(); + + await runner.OnStarted(metadata, CancellationToken.None); + + hook1.StartedCalled.Should().BeTrue(); + hook2.StartedCalled.Should().BeTrue(); + } + + [Test] + public async Task OnStarted_PassesMetadataToHook() + { + var hook = new RecordingHook(); + var factory = new DirectHookFactory(hook); + + using var runner = new LifecycleHookRunner([factory], _registry); + var metadata = CreateTestMetadata("TestTrain"); + + await runner.OnStarted(metadata, CancellationToken.None); + + hook.LastMetadata.Should().BeSameAs(metadata); + hook.LastMetadata!.Name.Should().Be("TestTrain"); + } + + [Test] + public async Task OnStarted_HookThrows_DoesNotPropagateException() + { + var throwingFactory = new ThrowingHookFactory(); + _registry.Register(typeof(ThrowingHookFactory), enabled: true); + + using var runner = new LifecycleHookRunner([throwingFactory], _registry); + var metadata = CreateTestMetadata(); + + var act = () => runner.OnStarted(metadata, CancellationToken.None); + + await act.Should().NotThrowAsync(); + } + + [Test] + public async Task OnStarted_HookThrows_ContinuesToNextHook() + { + var throwingFactory = new ThrowingHookFactory(); + var hook = new RecordingHook(); + var recordingFactory = new DirectHookFactory2(hook); + + _registry.Register(typeof(ThrowingHookFactory), enabled: true); + _registry.Register(typeof(DirectHookFactory2), enabled: true); + + using var runner = new LifecycleHookRunner([throwingFactory, recordingFactory], _registry); + var metadata = CreateTestMetadata(); + + await runner.OnStarted(metadata, CancellationToken.None); + + hook.StartedCalled.Should().BeTrue(); + } + + #endregion + + #region OnCompleted + + [Test] + public async Task OnCompleted_BroadcastsToAllHooks() + { + var hook1 = new RecordingHook(); + var hook2 = new RecordingHook(); + var factory1 = new DirectHookFactory(hook1); + var factory2 = new DirectHookFactory2(hook2); + + _registry.Register(typeof(DirectHookFactory), enabled: true); + _registry.Register(typeof(DirectHookFactory2), enabled: true); + + using var runner = new LifecycleHookRunner([factory1, factory2], _registry); + var metadata = CreateTestMetadata(); + + await runner.OnCompleted(metadata, CancellationToken.None); + + hook1.CompletedCalled.Should().BeTrue(); + hook2.CompletedCalled.Should().BeTrue(); + } + + [Test] + public async Task OnCompleted_HookThrows_DoesNotPropagate() + { + var throwingFactory = new ThrowingHookFactory(); + _registry.Register(typeof(ThrowingHookFactory), enabled: true); + + using var runner = new LifecycleHookRunner([throwingFactory], _registry); + + var act = () => runner.OnCompleted(CreateTestMetadata(), CancellationToken.None); + + await act.Should().NotThrowAsync(); + } + + #endregion + + #region OnFailed + + [Test] + public async Task OnFailed_BroadcastsToAllHooks() + { + var hook1 = new RecordingHook(); + var hook2 = new RecordingHook(); + var factory1 = new DirectHookFactory(hook1); + var factory2 = new DirectHookFactory2(hook2); + + _registry.Register(typeof(DirectHookFactory), enabled: true); + _registry.Register(typeof(DirectHookFactory2), enabled: true); + + using var runner = new LifecycleHookRunner([factory1, factory2], _registry); + var metadata = CreateTestMetadata(); + var exception = new InvalidOperationException("test failure"); + + await runner.OnFailed(metadata, exception, CancellationToken.None); + + hook1.FailedCalled.Should().BeTrue(); + hook1.LastException.Should().BeSameAs(exception); + hook2.FailedCalled.Should().BeTrue(); + } + + [Test] + public async Task OnFailed_HookThrows_DoesNotPropagate() + { + var throwingFactory = new ThrowingHookFactory(); + _registry.Register(typeof(ThrowingHookFactory), enabled: true); + + using var runner = new LifecycleHookRunner([throwingFactory], _registry); + + var act = () => + runner.OnFailed( + CreateTestMetadata(), + new Exception("train failure"), + CancellationToken.None + ); + + await act.Should().NotThrowAsync(); + } + + #endregion + + #region OnCancelled + + [Test] + public async Task OnCancelled_BroadcastsToAllHooks() + { + var hook1 = new RecordingHook(); + var hook2 = new RecordingHook(); + var factory1 = new DirectHookFactory(hook1); + var factory2 = new DirectHookFactory2(hook2); + + _registry.Register(typeof(DirectHookFactory), enabled: true); + _registry.Register(typeof(DirectHookFactory2), enabled: true); + + using var runner = new LifecycleHookRunner([factory1, factory2], _registry); + + await runner.OnCancelled(CreateTestMetadata(), CancellationToken.None); + + hook1.CancelledCalled.Should().BeTrue(); + hook2.CancelledCalled.Should().BeTrue(); + } + + [Test] + public async Task OnCancelled_HookThrows_DoesNotPropagate() + { + var throwingFactory = new ThrowingHookFactory(); + _registry.Register(typeof(ThrowingHookFactory), enabled: true); + + using var runner = new LifecycleHookRunner([throwingFactory], _registry); + + var act = () => runner.OnCancelled(CreateTestMetadata(), CancellationToken.None); + + await act.Should().NotThrowAsync(); + } + + [Test] + public async Task OnCancelled_PassesCancellationToken() + { + var hook = new RecordingHook(); + var factory = new DirectHookFactory(hook); + + using var runner = new LifecycleHookRunner([factory], _registry); + using var cts = new CancellationTokenSource(); + var token = cts.Token; + + await runner.OnCancelled(CreateTestMetadata(), token); + + hook.LastCancellationToken.Should().Be(token); + } + + #endregion + + #region Dispose + + [Test] + public void Dispose_DisposesDisposableHooks() + { + var hook = new DisposableRecordingHook(); + var factory = new DisposableHookFactory(hook); + + var runner = new LifecycleHookRunner([factory], _registry); + runner.Dispose(); + + hook.Disposed.Should().BeTrue(); + } + + [Test] + public void Dispose_NonDisposableHook_DoesNotThrow() + { + var hook = new RecordingHook(); + var factory = new DirectHookFactory(hook); + + var runner = new LifecycleHookRunner([factory], _registry); + var act = () => runner.Dispose(); + + act.Should().NotThrow(); + } + + [Test] + public void Dispose_HookDisposalThrows_DoesNotPropagate() + { + var hook = new ThrowingDisposableHook(); + var factory = new ThrowingDisposableHookFactory(hook); + + var runner = new LifecycleHookRunner([factory], _registry); + var act = () => runner.Dispose(); + + act.Should().NotThrow(); + } + + [Test] + public async Task Dispose_ClearsHooks_SubsequentCallsDoNotBroadcast() + { + var hook = new RecordingHook(); + var factory = new DirectHookFactory(hook); + + var runner = new LifecycleHookRunner([factory], _registry); + runner.Dispose(); + + // After dispose, hooks list is cleared — no broadcasts should happen + await runner.OnStarted(CreateTestMetadata(), CancellationToken.None); + + hook.StartedCalled.Should().BeFalse(); + } + + #endregion + + #region No Hooks Registered + + [Test] + public async Task OnStarted_NoHooks_DoesNotThrow() + { + using var runner = new LifecycleHookRunner([], _registry); + + var act = () => runner.OnStarted(CreateTestMetadata(), CancellationToken.None); + + await act.Should().NotThrowAsync(); + } + + [Test] + public async Task OnCompleted_NoHooks_DoesNotThrow() + { + using var runner = new LifecycleHookRunner([], _registry); + + var act = () => runner.OnCompleted(CreateTestMetadata(), CancellationToken.None); + + await act.Should().NotThrowAsync(); + } + + [Test] + public async Task OnFailed_NoHooks_DoesNotThrow() + { + using var runner = new LifecycleHookRunner([], _registry); + + var act = () => + runner.OnFailed(CreateTestMetadata(), new Exception("fail"), CancellationToken.None); + + await act.Should().NotThrowAsync(); + } + + [Test] + public async Task OnCancelled_NoHooks_DoesNotThrow() + { + using var runner = new LifecycleHookRunner([], _registry); + + var act = () => runner.OnCancelled(CreateTestMetadata(), CancellationToken.None); + + await act.Should().NotThrowAsync(); + } + + #endregion + + #region Default Interface Method + + [Test] + public async Task DefaultInterfaceMethod_OnlyOverriddenMethodsCalled() + { + var hook = new PartialHook(); + var factory = new PartialHookFactory(hook); + + using var runner = new LifecycleHookRunner([factory], _registry); + var metadata = CreateTestMetadata(); + + // PartialHook only overrides OnCompleted — others use default no-op + await runner.OnStarted(metadata, CancellationToken.None); + await runner.OnCompleted(metadata, CancellationToken.None); + await runner.OnFailed(metadata, new Exception(), CancellationToken.None); + await runner.OnCancelled(metadata, CancellationToken.None); + + hook.CompletedCalled.Should().BeTrue(); + } + + #endregion + + #region Test Helpers + + private static Metadata CreateTestMetadata(string name = "Test.Train") + { + return new Metadata { Name = name, ExternalId = Guid.NewGuid().ToString("N") }; + } + + private class RecordingHook : ITrainLifecycleHook + { + public bool StartedCalled { get; private set; } + public bool CompletedCalled { get; private set; } + public bool FailedCalled { get; private set; } + public bool CancelledCalled { get; private set; } + public Metadata? LastMetadata { get; private set; } + public Exception? LastException { get; private set; } + public CancellationToken LastCancellationToken { get; private set; } + + public Task OnStarted(Metadata metadata, CancellationToken ct) + { + StartedCalled = true; + LastMetadata = metadata; + LastCancellationToken = ct; + return Task.CompletedTask; + } + + public Task OnCompleted(Metadata metadata, CancellationToken ct) + { + CompletedCalled = true; + LastMetadata = metadata; + LastCancellationToken = ct; + return Task.CompletedTask; + } + + public Task OnFailed(Metadata metadata, Exception exception, CancellationToken ct) + { + FailedCalled = true; + LastMetadata = metadata; + LastException = exception; + LastCancellationToken = ct; + return Task.CompletedTask; + } + + public Task OnCancelled(Metadata metadata, CancellationToken ct) + { + CancelledCalled = true; + LastMetadata = metadata; + LastCancellationToken = ct; + return Task.CompletedTask; + } + } + + private class DisposableRecordingHook : ITrainLifecycleHook, IDisposable + { + public bool Disposed { get; private set; } + + public Task OnStarted(Metadata metadata, CancellationToken ct) => Task.CompletedTask; + + public Task OnCompleted(Metadata metadata, CancellationToken ct) => Task.CompletedTask; + + public Task OnFailed(Metadata metadata, Exception exception, CancellationToken ct) => + Task.CompletedTask; + + public Task OnCancelled(Metadata metadata, CancellationToken ct) => Task.CompletedTask; + + public void Dispose() => Disposed = true; + } + + private class ThrowingDisposableHook : ITrainLifecycleHook, IDisposable + { + public Task OnStarted(Metadata metadata, CancellationToken ct) => Task.CompletedTask; + + public Task OnCompleted(Metadata metadata, CancellationToken ct) => Task.CompletedTask; + + public Task OnFailed(Metadata metadata, Exception exception, CancellationToken ct) => + Task.CompletedTask; + + public Task OnCancelled(Metadata metadata, CancellationToken ct) => Task.CompletedTask; + + public void Dispose() => throw new InvalidOperationException("dispose failed"); + } + + private class ThrowingHook : ITrainLifecycleHook + { + public Task OnStarted(Metadata metadata, CancellationToken ct) => + throw new InvalidOperationException("hook failed"); + + public Task OnCompleted(Metadata metadata, CancellationToken ct) => + throw new InvalidOperationException("hook failed"); + + public Task OnFailed(Metadata metadata, Exception exception, CancellationToken ct) => + throw new InvalidOperationException("hook failed"); + + public Task OnCancelled(Metadata metadata, CancellationToken ct) => + throw new InvalidOperationException("hook failed"); + } + + /// + /// Hook that only overrides OnCompleted — all other methods use default no-op. + /// + private class PartialHook : ITrainLifecycleHook + { + public bool CompletedCalled { get; private set; } + + public Task OnCompleted(Metadata metadata, CancellationToken ct) + { + CompletedCalled = true; + return Task.CompletedTask; + } + } + + private class TrackingHookFactory : ITrainLifecycleHookFactory + { + public bool CreateCalled { get; private set; } + + public ITrainLifecycleHook Create() + { + CreateCalled = true; + return new RecordingHook(); + } + } + + private class DisabledTrackingHookFactory : ITrainLifecycleHookFactory + { + public bool CreateCalled { get; private set; } + + public ITrainLifecycleHook Create() + { + CreateCalled = true; + return new RecordingHook(); + } + } + + private class DirectHookFactory(ITrainLifecycleHook hook) : ITrainLifecycleHookFactory + { + public ITrainLifecycleHook Create() => hook; + } + + private class DirectHookFactory2(ITrainLifecycleHook hook) : ITrainLifecycleHookFactory + { + public ITrainLifecycleHook Create() => hook; + } + + private class ThrowingHookFactory : ITrainLifecycleHookFactory + { + public ITrainLifecycleHook Create() => new ThrowingHook(); + } + + private class DisposableHookFactory(DisposableRecordingHook hook) : ITrainLifecycleHookFactory + { + public ITrainLifecycleHook Create() => hook; + } + + private class ThrowingDisposableHookFactory(ThrowingDisposableHook hook) + : ITrainLifecycleHookFactory + { + public ITrainLifecycleHook Create() => hook; + } + + private class PartialHookFactory(PartialHook hook) : ITrainLifecycleHookFactory + { + public ITrainLifecycleHook Create() => hook; + } + + #endregion +} diff --git a/tests/Trax.Effect.Tests.Json.Integration/IntegrationTests/JsonEffectProviderTests.cs b/tests/Trax.Effect.Tests.Json.Integration/IntegrationTests/JsonEffectProviderTests.cs index 29ddf1d..5a04502 100644 --- a/tests/Trax.Effect.Tests.Json.Integration/IntegrationTests/JsonEffectProviderTests.cs +++ b/tests/Trax.Effect.Tests.Json.Integration/IntegrationTests/JsonEffectProviderTests.cs @@ -32,11 +32,12 @@ public async Task TestJsonEffect() train.Metadata.FailureStep.Should().BeNullOrEmpty(); train.Metadata.TrainState.Should().Be(TrainState.Completed); arrayProvider.Loggers.Should().NotBeNullOrEmpty(); - arrayProvider.Loggers.Should().HaveCount(5); + arrayProvider.Loggers.Should().HaveCount(6); // Verify that we have the expected logger types: // 1. Two train loggers (ILogger>) - may have empty logs // 2. One JsonEffectProvider logger (ILogger) - should have JSON logs + // 3. One LifecycleHookRunner logger (ILogger) - may have empty logs var jsonProviderLoggers = arrayProvider .Loggers.Where(logger => logger.Logs.Any(log =>