diff --git a/src/Trax.Effect/Models/Metadata/Metadata.cs b/src/Trax.Effect/Models/Metadata/Metadata.cs index d9fa5ac..65ecd1b 100644 --- a/src/Trax.Effect/Models/Metadata/Metadata.cs +++ b/src/Trax.Effect/Models/Metadata/Metadata.cs @@ -399,37 +399,38 @@ public static Metadata Create(CreateMetadata metadata) /// public Unit AddException(Exception trainException) { + // Priority 1: Structured data attached to the exception object (local execution) + if (trainException.Data["TrainExceptionData"] is TrainExceptionData data) + { + FailureException = data.Type; + FailureReason = data.Message; + FailureJunction = data.Junction; + StackTrace = data.StackTrace ?? trainException.StackTrace; + return Unit.Default; + } + + // Priority 2: JSON-serialized TrainExceptionData in the message (remote execution / legacy) try { - var deserializedException = JsonSerializer.Deserialize( + var deserialized = JsonSerializer.Deserialize( trainException.Message ); - - if (deserializedException == null) + if (deserialized != null) { - FailureException = trainException.GetType().Name; - FailureReason = trainException.Message; - FailureJunction = "TrainException"; - StackTrace = trainException.StackTrace; + FailureException = deserialized.Type; + FailureReason = deserialized.Message; + FailureJunction = deserialized.Junction; + StackTrace = deserialized.StackTrace ?? trainException.StackTrace; + return Unit.Default; } - else - { - FailureException = deserializedException.Type; - FailureReason = deserializedException.Message; - FailureJunction = deserializedException.Junction; - StackTrace = trainException.StackTrace; - } - - return Unit.Default; - } - catch (Exception) - { - FailureException = trainException.GetType().Name; - FailureReason = trainException.Message; - FailureJunction = "TrainException"; - StackTrace = trainException.StackTrace; } + catch { } + // Priority 3: Plain exception (no Trax context) + FailureException = trainException.GetType().Name; + FailureReason = trainException.Message; + FailureJunction = "TrainException"; + StackTrace = trainException.StackTrace; return Unit.Default; } diff --git a/tests/Trax.Effect.Tests.Broadcaster/UnitTests/BroadcastLifecycleHookTests.cs b/tests/Trax.Effect.Tests.Broadcaster/UnitTests/BroadcastLifecycleHookTests.cs index b07cc70..9839ec6 100644 --- a/tests/Trax.Effect.Tests.Broadcaster/UnitTests/BroadcastLifecycleHookTests.cs +++ b/tests/Trax.Effect.Tests.Broadcaster/UnitTests/BroadcastLifecycleHookTests.cs @@ -1,6 +1,7 @@ using System.Reflection; using FluentAssertions; using NSubstitute; +using Trax.Core.Exceptions; using Trax.Effect.Enums; using Trax.Effect.Extensions; using Trax.Effect.Models.Metadata; @@ -203,6 +204,65 @@ await _hook.OnFailed( captured.FailureReason.Should().Be("something broke"); } + [Test] + public async Task OnFailed_WithTrainExceptionData_BroadcastsJunctionAndReason() + { + var metadata = CreateMetadata(TrainState.Failed); + + var exception = new InvalidOperationException("connection refused"); + exception.Data["TrainExceptionData"] = new TrainExceptionData + { + TrainName = "TestTrain", + TrainExternalId = "ext-1", + Type = "InvalidOperationException", + Junction = "DatabaseJunction", + Message = "connection refused", + StackTrace = "at App.DatabaseJunction.Run()", + }; + metadata.AddException(exception); + + TrainLifecycleEventMessage? captured = null; + await _broadcaster.PublishAsync( + Arg.Do(m => captured = m), + Arg.Any() + ); + + await _hook.OnFailed(metadata, exception, CancellationToken.None); + + captured!.FailureJunction.Should().Be("DatabaseJunction"); + captured.FailureReason.Should().Be("connection refused"); + } + + [Test] + public async Task OnFailed_FailureReasonIsNotJsonBlob() + { + var metadata = CreateMetadata(TrainState.Failed); + + var exception = new HttpRequestException("500 Internal Server Error"); + exception.Data["TrainExceptionData"] = new TrainExceptionData + { + TrainName = "TestTrain", + TrainExternalId = "ext-1", + Type = "HttpRequestException", + Junction = "ApiCallJunction", + Message = "500 Internal Server Error", + StackTrace = "at App.ApiCallJunction.Run()", + }; + metadata.AddException(exception); + + TrainLifecycleEventMessage? captured = null; + await _broadcaster.PublishAsync( + Arg.Do(m => captured = m), + Arg.Any() + ); + + await _hook.OnFailed(metadata, exception, CancellationToken.None); + + // The broadcaster should not receive a JSON blob as the failure reason + captured!.FailureReason.Should().NotStartWith("{"); + captured.FailureReason.Should().Be("500 Internal Server Error"); + } + [Test] public async Task PassesCancellationTokenToBroadcaster() { diff --git a/tests/Trax.Effect.Tests.Data.InMemory.Integration/IntegrationTests/ExceptionPersistenceTests.cs b/tests/Trax.Effect.Tests.Data.InMemory.Integration/IntegrationTests/ExceptionPersistenceTests.cs new file mode 100644 index 0000000..74434e4 --- /dev/null +++ b/tests/Trax.Effect.Tests.Data.InMemory.Integration/IntegrationTests/ExceptionPersistenceTests.cs @@ -0,0 +1,406 @@ +using FluentAssertions; +using LanguageExt; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Trax.Core.Exceptions; +using Trax.Core.Junction; +using Trax.Effect.Data.Services.DataContext; +using Trax.Effect.Enums; +using Trax.Effect.Extensions; +using Trax.Effect.Services.ServiceTrain; +using Trax.Effect.Tests.Data.InMemory.Integration.Fixtures; + +namespace Trax.Effect.Tests.Data.InMemory.Integration.IntegrationTests; + +/// +/// End-to-end tests verifying that exception context from the full +/// Junction → Monad → ServiceTrain → Metadata → Database pipeline +/// produces correct, readable failure fields for the dashboard, API, and broadcaster. +/// +public class ExceptionPersistenceTests : TestSetup +{ + public override ServiceProvider ConfigureServices(IServiceCollection services) => + services + .AddScopedTraxRoute() + .AddScopedTraxRoute() + .AddScopedTraxRoute() + .AddScopedTraxRoute() + .AddScopedTraxRoute() + .AddScopedTraxRoute() + .BuildServiceProvider(); + + #region Junction Exception → Metadata Fields + + [Test] + public async Task JunctionFails_FailureFieldsPopulatedCorrectly() + { + var train = Scope.ServiceProvider.GetRequiredService(); + + var act = async () => await train.Run("input"); + await act.Should().ThrowAsync(); + + train.Metadata.Should().NotBeNull(); + train.Metadata!.TrainState.Should().Be(TrainState.Failed); + train.Metadata.FailureException.Should().Be("InvalidOperationException"); + train.Metadata.FailureReason.Should().Be("junction failure"); + train.Metadata.FailureJunction.Should().Be(nameof(AlwaysFailsJunction)); + } + + [Test] + public async Task JunctionFails_StackTraceContainsOriginalThrowSite() + { + var train = Scope.ServiceProvider.GetRequiredService(); + + var act = async () => await train.Run("input"); + await act.Should().ThrowAsync(); + + train.Metadata.Should().NotBeNull(); + train.Metadata!.StackTrace.Should().NotBeNullOrEmpty(); + train.Metadata.StackTrace.Should().Contain(nameof(AlwaysFailsJunction)); + } + + [Test] + public async Task JunctionFails_OriginalExceptionTypePreserved() + { + var train = Scope.ServiceProvider.GetRequiredService(); + + // The exception thrown to the caller should be the ORIGINAL type + var act = async () => await train.Run("input"); + var ex = (await act.Should().ThrowAsync()).Which; + + // The message should be the ORIGINAL human-readable message, not JSON + ex.Message.Should().Be("junction failure"); + } + + #endregion + + #region HTTP Error Exception (simulating real-world scenario) + + [Test] + public async Task HttpError_FailureReasonContainsOriginalMessage() + { + var train = Scope.ServiceProvider.GetRequiredService(); + + var act = async () => await train.Run("input"); + await act.Should().ThrowAsync(); + + train.Metadata.Should().NotBeNull(); + train.Metadata!.FailureException.Should().Be("HttpRequestException"); + train + .Metadata.FailureReason.Should() + .Be("Response status code does not indicate success: 500 (Internal Server Error)."); + train.Metadata.FailureJunction.Should().Be(nameof(HttpCallJunction)); + train.Metadata.StackTrace.Should().Contain(nameof(HttpCallJunction)); + } + + [Test] + public async Task HttpError_ExceptionThrownToCallerIsOriginal() + { + var train = Scope.ServiceProvider.GetRequiredService(); + + var act = async () => await train.Run("input"); + var ex = (await act.Should().ThrowAsync()).Which; + + // Must be the original message, NOT a JSON blob + ex.Message.Should() + .Be("Response status code does not indicate success: 500 (Internal Server Error)."); + } + + #endregion + + #region JSON Content in Exception Message + + [Test] + public async Task JsonInExceptionMessage_FailureReasonPreservesOriginalJson() + { + var train = Scope.ServiceProvider.GetRequiredService(); + + var act = async () => await train.Run("input"); + await act.Should().ThrowAsync(); + + train.Metadata.Should().NotBeNull(); + + // The FailureReason must be the original JSON string, not double-encoded + train.Metadata!.FailureReason.Should().Contain("\"success\":false"); + train.Metadata.FailureReason.Should().Contain("\"referenceId\":\"ref-123\""); + train.Metadata.FailureJunction.Should().Be(nameof(JsonExceptionJunction)); + } + + [Test] + public async Task JsonInExceptionMessage_MessageNotCorruptedByTraxPipeline() + { + var train = Scope.ServiceProvider.GetRequiredService(); + + var act = async () => await train.Run("input"); + var ex = (await act.Should().ThrowAsync()).Which; + + // The original JSON message must be intact — not wrapped or mutated + ex.Message.Should().Contain("\"success\":false"); + } + + #endregion + + #region Special Characters in Exception Message + + [Test] + public async Task SpecialCharsInMessage_FailureReasonPreserved() + { + var train = Scope.ServiceProvider.GetRequiredService(); + + var act = async () => await train.Run("input"); + await act.Should().ThrowAsync(); + + train.Metadata.Should().NotBeNull(); + train.Metadata!.FailureReason.Should().Contain("newlines"); + train.Metadata.FailureReason.Should().Contain("tabs"); + train.Metadata.FailureReason.Should().Contain("quotes"); + train.Metadata.FailureReason.Should().Contain("backslashes"); + } + + #endregion + + #region Multi-Junction Train (failure in second junction) + + [Test] + public async Task MultiJunction_SecondJunctionFails_CorrectJunctionIdentified() + { + var train = Scope.ServiceProvider.GetRequiredService(); + + var act = async () => await train.Run("input"); + await act.Should().ThrowAsync(); + + train.Metadata.Should().NotBeNull(); + train.Metadata!.FailureJunction.Should().Be(nameof(SecondJunctionFails)); + train.Metadata.FailureReason.Should().Be("second junction failed"); + train.Metadata.StackTrace.Should().Contain(nameof(SecondJunctionFails)); + } + + #endregion + + #region Plain Exception (not from junction — RunInternal override) + + [Test] + public async Task PlainException_NotFromJunction_StillPersistsFailureFields() + { + var train = Scope.ServiceProvider.GetRequiredService(); + + var act = async () => await train.Run(Unit.Default); + await act.Should().ThrowAsync(); + + train.Metadata.Should().NotBeNull(); + train.Metadata!.TrainState.Should().Be(TrainState.Failed); + train.Metadata.FailureException.Should().Be("ArgumentException"); + train.Metadata.FailureReason.Should().Be("plain failure"); + // No junction context — falls back to "TrainException" sentinel + train.Metadata.FailureJunction.Should().Be("TrainException"); + } + + #endregion + + #region Database Persistence Round-Trip + + [Test] + public async Task JunctionFails_FailureFieldsPersistedToDatabase() + { + var train = Scope.ServiceProvider.GetRequiredService(); + + var act = async () => await train.Run("input"); + await act.Should().ThrowAsync(); + + // Read back from the database (not from the in-memory train object) + var dataContext = Scope.ServiceProvider.GetRequiredService(); + var persisted = await dataContext.Metadatas.FirstOrDefaultAsync(m => + m.Id == train.Metadata!.Id + ); + + persisted.Should().NotBeNull(); + persisted!.TrainState.Should().Be(TrainState.Failed); + persisted.FailureException.Should().Be("InvalidOperationException"); + persisted.FailureReason.Should().Be("junction failure"); + persisted.FailureJunction.Should().Be(nameof(AlwaysFailsJunction)); + persisted.StackTrace.Should().Contain(nameof(AlwaysFailsJunction)); + } + + [Test] + public async Task HttpError_FailureFieldsPersistedToDatabase() + { + var train = Scope.ServiceProvider.GetRequiredService(); + + var act = async () => await train.Run("input"); + await act.Should().ThrowAsync(); + + var dataContext = Scope.ServiceProvider.GetRequiredService(); + var persisted = await dataContext.Metadatas.FirstOrDefaultAsync(m => + m.Id == train.Metadata!.Id + ); + + persisted.Should().NotBeNull(); + persisted!.FailureException.Should().Be("HttpRequestException"); + persisted + .FailureReason.Should() + .Be("Response status code does not indicate success: 500 (Internal Server Error)."); + persisted.FailureJunction.Should().Be(nameof(HttpCallJunction)); + persisted.StackTrace.Should().NotBeNullOrEmpty(); + } + + [Test] + public async Task JsonInMessage_FailureFieldsPersistedToDatabase() + { + var train = Scope.ServiceProvider.GetRequiredService(); + + var act = async () => await train.Run("input"); + await act.Should().ThrowAsync(); + + var dataContext = Scope.ServiceProvider.GetRequiredService(); + var persisted = await dataContext.Metadatas.FirstOrDefaultAsync(m => + m.Id == train.Metadata!.Id + ); + + persisted.Should().NotBeNull(); + persisted!.FailureReason.Should().Contain("\"success\":false"); + persisted.FailureReason.Should().Contain("\"referenceId\":\"ref-123\""); + } + + #endregion + + #region Dashboard / API Compatibility + + [Test] + public async Task JunctionFails_FailureFieldsAreAllPlainStrings() + { + // The dashboard reads these fields as plain strings (ExceptionViewer.razor). + // The GraphQL API exposes FailureJunction and FailureReason as string scalars. + // None of these should be JSON or require further parsing. + + var train = Scope.ServiceProvider.GetRequiredService(); + + var act = async () => await train.Run("input"); + await act.Should().ThrowAsync(); + + // FailureException should be a simple type name, not a full namespace or JSON + train.Metadata!.FailureException.Should().NotContain("{"); + train.Metadata.FailureException.Should().NotContain("\""); + + // FailureJunction should be a simple class name + train.Metadata.FailureJunction.Should().NotContain("{"); + train.Metadata.FailureJunction.Should().NotContain("\""); + + // FailureReason should be the original human-readable message + train.Metadata.FailureReason.Should().Be("junction failure"); + + // StackTrace should look like a .NET stack trace + train.Metadata.StackTrace.Should().Contain("at "); + } + + [Test] + public async Task HttpError_FailureReasonIsNotJsonBlob() + { + // Previously, RailwayJunction mutated the exception message to be a JSON blob. + // This would cause the dashboard to show raw JSON instead of a readable message. + // Verify that the FailureReason is the original human-readable message. + + var train = Scope.ServiceProvider.GetRequiredService(); + + var act = async () => await train.Run("input"); + await act.Should().ThrowAsync(); + + // The failure reason must NOT start with { — that would indicate a JSON blob + train.Metadata!.FailureReason.Should().NotStartWith("{"); + train.Metadata.FailureReason.Should().StartWith("Response status code"); + } + + #endregion + + #region Junctions + + private class AlwaysFailsJunction : Junction + { + public override Task Run(string input) => + throw new InvalidOperationException("junction failure"); + } + + private class HttpCallJunction : Junction + { + public override Task Run(string input) => + throw new HttpRequestException( + "Response status code does not indicate success: 500 (Internal Server Error)." + ); + } + + private class JsonExceptionJunction : Junction + { + public override Task Run(string input) => + throw new InvalidOperationException( + """{"success":false,"referenceId":"ref-123","error":"payment declined"}""" + ); + } + + private class SpecialCharsJunction : Junction + { + public override Task Run(string input) => + throw new InvalidOperationException( + "Message with\nnewlines,\ttabs, \"quotes\", and \\backslashes" + ); + } + + private class FirstJunctionSucceeds : Junction + { + public override Task Run(string input) => Task.FromResult(input + "-processed"); + } + + private class SecondJunctionFails : Junction + { + public override Task Run(string input) => + throw new InvalidOperationException("second junction failed"); + } + + #endregion + + #region Trains + + private class JunctionFailingTrain : ServiceTrain, IJunctionFailingTrain + { + protected override string Junctions() => Chain(); + } + + private interface IJunctionFailingTrain : IServiceTrain { } + + private class HttpErrorTrain : ServiceTrain, IHttpErrorTrain + { + protected override string Junctions() => Chain(); + } + + private interface IHttpErrorTrain : IServiceTrain { } + + private class JsonMessageTrain : ServiceTrain, IJsonMessageTrain + { + protected override string Junctions() => Chain(); + } + + private interface IJsonMessageTrain : IServiceTrain { } + + private class SpecialCharsTrain : ServiceTrain, ISpecialCharsTrain + { + protected override string Junctions() => Chain(); + } + + private interface ISpecialCharsTrain : IServiceTrain { } + + private class MultiJunctionTrain : ServiceTrain, IMultiJunctionTrain + { + protected override string Junctions() => + Chain().Chain(); + } + + private interface IMultiJunctionTrain : IServiceTrain { } + + private class PlainExceptionTrain : ServiceTrain, IPlainExceptionTrain + { + protected override async Task> RunInternal(Unit input) => + new ArgumentException("plain failure"); + } + + private interface IPlainExceptionTrain : IServiceTrain { } + + #endregion +}