diff --git a/src/Trax.Core/Exceptions/TrainExceptionData.cs b/src/Trax.Core/Exceptions/TrainExceptionData.cs index e867eab..306c9a8 100644 --- a/src/Trax.Core/Exceptions/TrainExceptionData.cs +++ b/src/Trax.Core/Exceptions/TrainExceptionData.cs @@ -31,4 +31,11 @@ public class TrainExceptionData /// [JsonPropertyName("message")] public required string Message { get; set; } + + /// + /// The original stack trace from where the exception was thrown. + /// Nullable for backwards compatibility with previously serialized data. + /// + [JsonPropertyName("stackTrace")] + public string? StackTrace { get; set; } } diff --git a/src/Trax.Core/Junction/Junction.cs b/src/Trax.Core/Junction/Junction.cs index bc9ea5e..befdd83 100644 --- a/src/Trax.Core/Junction/Junction.cs +++ b/src/Trax.Core/Junction/Junction.cs @@ -1,6 +1,4 @@ using System.ComponentModel; -using System.Reflection; -using System.Text.Json; using LanguageExt; using LanguageExt.UnsafeValueAccess; using Trax.Core.Exceptions; @@ -96,15 +94,6 @@ Train train } catch (Exception e) { - // Enrich the exception with junction information for better debugging - var messageField = typeof(Exception).GetField( - "_message", - BindingFlags.Instance | BindingFlags.NonPublic - ); - - if (messageField is null) - return e; - var exceptionData = new TrainExceptionData { TrainName = train.GetType().Name, @@ -112,14 +101,16 @@ Train train Junction = GetType().Name, Type = e.GetType().Name, Message = e.Message, + StackTrace = e.StackTrace, }; ExceptionData = exceptionData; - var serializedMessage = JsonSerializer.Serialize(exceptionData); - messageField.SetValue(e, serializedMessage); + // Store structured data on the exception without mutating its message. + // This preserves the original exception for callers outside Trax, + // while still making junction context available to Metadata.AddException(). + e.Data["TrainExceptionData"] = exceptionData; - // Return the exception as Left return e; } } diff --git a/src/Trax.Core/Monad/Monad.Resolve.cs b/src/Trax.Core/Monad/Monad.Resolve.cs index c2905ae..c4dab43 100644 --- a/src/Trax.Core/Monad/Monad.Resolve.cs +++ b/src/Trax.Core/Monad/Monad.Resolve.cs @@ -1,3 +1,4 @@ +using System.Runtime.ExceptionServices; using LanguageExt; using Trax.Core.Exceptions; using Trax.Core.Extensions; @@ -50,7 +51,7 @@ public Either Resolve() internal TReturn ResolveOrThrow() { if (Exception is not null) - throw Exception; + ExceptionDispatchInfo.Capture(Exception).Throw(); if (ShortCircuitValueSet) return ShortCircuitValue; diff --git a/tests/Trax.Core.Tests.Unit/UnitTests/Exceptions/TrainExceptionDataTests.cs b/tests/Trax.Core.Tests.Unit/UnitTests/Exceptions/TrainExceptionDataTests.cs index 2bc10ce..fc6bd72 100644 --- a/tests/Trax.Core.Tests.Unit/UnitTests/Exceptions/TrainExceptionDataTests.cs +++ b/tests/Trax.Core.Tests.Unit/UnitTests/Exceptions/TrainExceptionDataTests.cs @@ -98,4 +98,93 @@ public async Task Deserialize_ValidJson_CreatesCorrectObject() data.Junction.Should().Be("ParseJunction"); data.Message.Should().Be("bad arg"); } + + #region StackTrace Serialization + + [Theory] + public async Task Serialize_RoundTrip_PreservesStackTrace() + { + // Arrange + var data = new TrainExceptionData + { + TrainName = "MyTrain", + TrainExternalId = "ext-123", + Type = "InvalidOperationException", + Junction = "ValidateInput", + Message = "Input was null", + StackTrace = " at MyApp.ValidateInput.Run() in /app/Validate.cs:line 42", + }; + + // Act + var json = JsonSerializer.Serialize(data); + var deserialized = JsonSerializer.Deserialize(json); + + // Assert + deserialized.Should().NotBeNull(); + deserialized!.StackTrace.Should().Be(data.StackTrace); + } + + [Theory] + public async Task Serialize_NullStackTrace_HandledCorrectly() + { + // Arrange + var data = new TrainExceptionData + { + TrainName = "MyTrain", + TrainExternalId = "ext-123", + Type = "Exception", + Junction = "Junction", + Message = "msg", + StackTrace = null, + }; + + // Act + var json = JsonSerializer.Serialize(data); + var deserialized = JsonSerializer.Deserialize(json); + + // Assert + deserialized.Should().NotBeNull(); + deserialized!.StackTrace.Should().BeNull(); + } + + [Theory] + public async Task Deserialize_WithoutStackTraceField_BackwardsCompatible() + { + // Arrange — JSON from an older version that doesn't include stackTrace + var json = + """{"trainName":"Test","trainExternalId":"id","type":"Exception","junction":"J","message":"msg"}"""; + + // Act + var data = JsonSerializer.Deserialize(json); + + // Assert — should deserialize successfully with null StackTrace + data.Should().NotBeNull(); + data!.StackTrace.Should().BeNull(); + data.Message.Should().Be("msg"); + } + + [Theory] + public async Task Serialize_StackTraceWithSpecialCharacters_PreservedCorrectly() + { + // Arrange + var data = new TrainExceptionData + { + TrainName = "Test", + TrainExternalId = "id", + Type = "Exception", + Junction = "J", + Message = "msg", + StackTrace = + " at MyApp.Run() in C:\\Users\\dev\\src\\App.cs:line 10\n at System.Threading.Tasks.Task.Execute()", + }; + + // Act + var json = JsonSerializer.Serialize(data); + var deserialized = JsonSerializer.Deserialize(json); + + // Assert + deserialized!.StackTrace.Should().Be(data.StackTrace); + } + + #endregion } diff --git a/tests/Trax.Core.Tests.Unit/UnitTests/Junction/JunctionCancellationTests.cs b/tests/Trax.Core.Tests.Unit/UnitTests/Junction/JunctionCancellationTests.cs index 9e44181..81c9767 100644 --- a/tests/Trax.Core.Tests.Unit/UnitTests/Junction/JunctionCancellationTests.cs +++ b/tests/Trax.Core.Tests.Unit/UnitTests/Junction/JunctionCancellationTests.cs @@ -1,6 +1,7 @@ using FluentAssertions; using LanguageExt; using LanguageExt.UnsafeValueAccess; +using Trax.Core.Exceptions; using Trax.Core.Junction; using Trax.Core.Train; @@ -105,6 +106,84 @@ public async Task RailwayStep_NonCancellationException_StillWrapsInExceptionData junction.ExceptionData!.Junction.Should().Be(nameof(ThrowingStep)); } + [Theory] + public async Task RailwayJunction_ExceptionThrown_OriginalMessagePreserved() + { + // Arrange + var junction = new ThrowingStep(); + var train = new TestTrain(); + + Either input = "hello"; + + // Act + var result = await junction.RailwayJunction(input, train); + + // Assert — the exception message must be the original, not JSON + var exception = result.Swap().ValueUnsafe(); + exception.Message.Should().Be("test error"); + } + + [Theory] + public async Task RailwayJunction_ExceptionThrown_ExceptionDataAttachedViaDataDictionary() + { + // Arrange + var junction = new ThrowingStep(); + var train = new TestTrain(); + + Either input = "hello"; + + // Act + var result = await junction.RailwayJunction(input, train); + + // Assert — TrainExceptionData stored in Exception.Data dictionary + var exception = result.Swap().ValueUnsafe(); + exception.Data["TrainExceptionData"].Should().NotBeNull(); + var data = exception.Data["TrainExceptionData"] as TrainExceptionData; + data.Should().NotBeNull(); + data!.Junction.Should().Be(nameof(ThrowingStep)); + data.Type.Should().Be("InvalidOperationException"); + data.Message.Should().Be("test error"); + data.TrainName.Should().Be(nameof(TestTrain)); + } + + [Theory] + public async Task RailwayJunction_ExceptionThrown_OriginalStackTraceInExceptionData() + { + // Arrange + var junction = new ThrowingStep(); + var train = new TestTrain(); + + Either input = "hello"; + + // Act + var result = await junction.RailwayJunction(input, train); + + // Assert — the captured StackTrace should contain the junction's Run method + var exception = result.Swap().ValueUnsafe(); + var data = exception.Data["TrainExceptionData"] as TrainExceptionData; + data.Should().NotBeNull(); + data!.StackTrace.Should().NotBeNullOrEmpty(); + data.StackTrace.Should().Contain(nameof(ThrowingStep)); + } + + [Theory] + public async Task RailwayJunction_ExceptionThrown_ExceptionDataAndPropertyAreConsistent() + { + // Arrange + var junction = new ThrowingStep(); + var train = new TestTrain(); + + Either input = "hello"; + + // Act + var result = await junction.RailwayJunction(input, train); + + // Assert — Junction.ExceptionData property and Exception.Data dictionary should match + var exception = result.Swap().ValueUnsafe(); + var dictionaryData = exception.Data["TrainExceptionData"] as TrainExceptionData; + junction.ExceptionData.Should().BeSameAs(dictionaryData); + } + #region Test Helpers private class TokenVerifyingStep : Junction diff --git a/tests/Trax.Core.Tests.Unit/UnitTests/Junction/StackTracePreservationTests.cs b/tests/Trax.Core.Tests.Unit/UnitTests/Junction/StackTracePreservationTests.cs new file mode 100644 index 0000000..52489eb --- /dev/null +++ b/tests/Trax.Core.Tests.Unit/UnitTests/Junction/StackTracePreservationTests.cs @@ -0,0 +1,190 @@ +using FluentAssertions; +using LanguageExt; +using LanguageExt.UnsafeValueAccess; +using Trax.Core.Exceptions; +using Trax.Core.Junction; +using Trax.Core.Train; + +namespace Trax.Core.Tests.Unit.UnitTests.Step; + +/// +/// Verifies that original exception identity (message, stack trace, type) is preserved +/// through the full Junction → Monad → ResolveOrThrow chain. +/// +public class StackTracePreservationTests : TestSetup +{ + #region ResolveOrThrow Stack Trace Preservation + + [Theory] + public async Task Run_ThrowingJunction_PreservesOriginalStackTrace() + { + // Arrange + var train = new ThrowingTrain(); + + // Act + Exception? caught = null; + try + { + await train.Run("input"); + } + catch (Exception ex) + { + caught = ex; + } + + // Assert — the stack trace should contain the original throw site + caught.Should().NotBeNull(); + caught!.StackTrace.Should().Contain(nameof(AlwaysThrowsJunction)); + } + + [Theory] + public async Task Run_ThrowingJunction_StackTraceDoesNotStartAtResolveOrThrow() + { + // Arrange + var train = new ThrowingTrain(); + + // Act + Exception? caught = null; + try + { + await train.Run("input"); + } + catch (Exception ex) + { + caught = ex; + } + + // Assert — the first frame should NOT be ResolveOrThrow + caught.Should().NotBeNull(); + var firstLine = caught!.StackTrace!.Split('\n')[0]; + firstLine.Should().NotContain("ResolveOrThrow"); + } + + #endregion + + #region RunEither (Either Path) Stack Trace Preservation + + [Theory] + public async Task RunEither_ThrowingJunction_ReturnsExceptionWithOriginalStackTrace() + { + // Arrange + var train = new ThrowingTrain(); + + // Act + var result = await train.RunEither("input"); + + // Assert + result.IsLeft.Should().BeTrue(); + var exception = result.Swap().ValueUnsafe(); + exception.StackTrace.Should().Contain(nameof(AlwaysThrowsJunction)); + } + + #endregion + + #region Original Message Preservation + + [Theory] + public async Task Run_ThrowingJunction_ExceptionHasOriginalMessage() + { + // Arrange + var train = new ThrowingTrain(); + + // Act + Exception? caught = null; + try + { + await train.Run("input"); + } + catch (Exception ex) + { + caught = ex; + } + + // Assert — the message should be the original, not JSON + caught.Should().NotBeNull(); + caught!.Message.Should().Be("something went wrong"); + caught.Should().BeOfType(); + } + + [Theory] + public async Task Run_ThrowingJunction_ExceptionDataContainsStructuredInfo() + { + // Arrange + var train = new ThrowingTrain(); + + // Act + Exception? caught = null; + try + { + await train.Run("input"); + } + catch (Exception ex) + { + caught = ex; + } + + // Assert — structured data available via Exception.Data + caught.Should().NotBeNull(); + var data = caught!.Data["TrainExceptionData"] as TrainExceptionData; + data.Should().NotBeNull(); + data!.Junction.Should().Be(nameof(AlwaysThrowsJunction)); + data.Type.Should().Be("InvalidOperationException"); + data.Message.Should().Be("something went wrong"); + data.StackTrace.Should().Contain(nameof(AlwaysThrowsJunction)); + } + + #endregion + + #region Exception Type Preservation + + [Theory] + public async Task Run_ThrowingJunction_OriginalExceptionTypePreserved() + { + // Arrange + var train = new ArgumentThrowingTrain(); + + // Act + Exception? caught = null; + try + { + await train.Run("input"); + } + catch (Exception ex) + { + caught = ex; + } + + // Assert — the exception type should be the original, not TrainException + caught.Should().NotBeNull(); + caught.Should().BeOfType(); + caught!.Message.Should().Be("bad argument"); + } + + #endregion + + #region Test Helpers + + private class AlwaysThrowsJunction : Junction + { + public override Task Run(string input) => + throw new InvalidOperationException("something went wrong"); + } + + private class ArgumentThrowsJunction : Junction + { + public override Task Run(string input) => + throw new ArgumentException("bad argument"); + } + + private class ThrowingTrain : Train + { + protected override string Junctions() => Chain(); + } + + private class ArgumentThrowingTrain : Train + { + protected override string Junctions() => Chain(); + } + + #endregion +} diff --git a/tests/Trax.Core.Tests/Tests/JsonEscapingTests.cs b/tests/Trax.Core.Tests/Tests/JsonEscapingTests.cs index 03bfca8..9a3116e 100644 --- a/tests/Trax.Core.Tests/Tests/JsonEscapingTests.cs +++ b/tests/Trax.Core.Tests/Tests/JsonEscapingTests.cs @@ -9,8 +9,9 @@ namespace Trax.Core.Tests.Tests; /// -/// Tests to verify that JSON content in exception messages is properly escaped -/// when exceptions are enriched with junction information. +/// Tests to verify that exception messages with special characters (including JSON content) +/// are preserved in their original form, and that structured junction context is available +/// via Exception.Data["TrainExceptionData"]. /// public class JsonEscapingTests { @@ -22,14 +23,12 @@ protected override Task> RunInternal(string input) => /// /// Test junction that throws an exception with JSON content in the message. - /// This simulates the scenario described in the issue where a CybersourcePaymentsException - /// contains JSON in its message. + /// This simulates the scenario where an API exception contains JSON in its message. /// private class TestJunctionWithJsonException : Junction { public override Task Run(string input) { - // Simulate an exception with JSON content in the message (like CybersourcePaymentsException) var jsonMessage = """{"success":false,"referenceId":"reference-me2","amount":null,"id":"7551812047776009403814","submitTimeUtc":null,"cardType":null,"metadata":{"cybersourceReason":"Decline - Insufficient funds in the account.","statusReason":"The credit card was declined with a reason.","processorReason":"Decline - Insufficient funds in the account.","cardVerificationReason":null,"addressVerificationReason":null},"attemptCount":2}"""; @@ -38,7 +37,7 @@ public override Task Run(string input) } [Test] - public async Task RailwayJunction_WhenExceptionContainsJson_ShouldProduceValidJson() + public async Task RailwayJunction_WhenExceptionContainsJson_OriginalMessagePreserved() { // Arrange var junction = new TestJunctionWithJsonException(); @@ -56,28 +55,36 @@ public async Task RailwayJunction_WhenExceptionContainsJson_ShouldProduceValidJs ); var exception = result.Swap().ValueUnsafe(); - var exceptionMessage = exception.Message; - // Verify that the exception message is valid JSON - Assert.That( - IsValidJson(exceptionMessage), - Is.True, - $"Exception message should be valid JSON, but got: {exceptionMessage}" - ); + // The original message should be preserved (not replaced with TrainExceptionData JSON) + Assert.That(exception.Message, Does.Contain("\"success\":false")); + Assert.That(exception.Message, Does.Contain("\"referenceId\":\"reference-me2\"")); + } + + [Test] + public async Task RailwayJunction_WhenExceptionContainsJson_ExceptionDataAvailable() + { + // Arrange + var junction = new TestJunctionWithJsonException(); + var input = Either.Right("test input"); + var train = new DummyTrain(); + + // Act + var result = await junction.RailwayJunction(input, train); - // Verify that we can deserialize the exception message - var exceptionData = JsonSerializer.Deserialize(exceptionMessage); - Assert.That(exceptionData, Is.Not.Null); - Assert.That(exceptionData.Junction, Is.EqualTo("TestJunctionWithJsonException")); - Assert.That(exceptionData.Type, Is.EqualTo("InvalidOperationException")); + // Assert + var exception = result.Swap().ValueUnsafe(); - // Verify that the original JSON message is properly escaped within the message property - Assert.That(exceptionData.Message, Does.Contain("\"success\":false")); - Assert.That(exceptionData.Message, Does.Contain("\"referenceId\":\"reference-me2\"")); + // Structured data should be available via Exception.Data + var data = exception.Data["TrainExceptionData"] as TrainExceptionData; + Assert.That(data, Is.Not.Null); + Assert.That(data!.Junction, Is.EqualTo("TestJunctionWithJsonException")); + Assert.That(data.Type, Is.EqualTo("InvalidOperationException")); + Assert.That(data.Message, Does.Contain("\"success\":false")); } [Test] - public async Task RailwayJunction_WhenExceptionContainsSpecialCharacters_ShouldProduceValidJson() + public async Task RailwayJunction_WhenExceptionContainsSpecialCharacters_OriginalMessagePreserved() { // Arrange var junction = new TestJunctionWithSpecialCharacters(); @@ -88,32 +95,67 @@ public async Task RailwayJunction_WhenExceptionContainsSpecialCharacters_ShouldP var result = await junction.RailwayJunction(input, train); // Assert - Assert.That( - result.IsLeft, - Is.True, - "Expected the junction to fail and return Left(Exception)" - ); + var exception = result.Swap().ValueUnsafe(); + + // The original message should be preserved + Assert.That(exception.Message, Does.Contain("quotes")); + Assert.That(exception.Message, Does.Contain("newlines")); + Assert.That(exception.Message, Does.Contain("backslashes")); + } + [Test] + public async Task RailwayJunction_WhenExceptionContainsSpecialCharacters_ExceptionDataAvailable() + { + // Arrange + var junction = new TestJunctionWithSpecialCharacters(); + var input = Either.Right("test input"); + var train = new DummyTrain(); + + // Act + var result = await junction.RailwayJunction(input, train); + + // Assert var exception = result.Swap().ValueUnsafe(); - var exceptionMessage = exception.Message; - // Verify that the exception message is valid JSON + // Structured data should be available via Exception.Data + var data = exception.Data["TrainExceptionData"] as TrainExceptionData; + Assert.That(data, Is.Not.Null); + Assert.That(data!.Junction, Is.EqualTo("TestJunctionWithSpecialCharacters")); + Assert.That(data.Type, Is.EqualTo("InvalidOperationException")); + + // Original message preserved in the data object + Assert.That(data.Message, Does.Contain("quotes")); + Assert.That(data.Message, Does.Contain("newlines")); + Assert.That(data.Message, Does.Contain("backslashes")); + } + + [Test] + public async Task RailwayJunction_ExceptionDataMessage_CanBeSerializedToValidJson() + { + // Arrange + var junction = new TestJunctionWithJsonException(); + var input = Either.Right("test input"); + var train = new DummyTrain(); + + // Act + var result = await junction.RailwayJunction(input, train); + + // Assert — the TrainExceptionData can be serialized to valid JSON + var exception = result.Swap().ValueUnsafe(); + var data = exception.Data["TrainExceptionData"] as TrainExceptionData; + Assert.That(data, Is.Not.Null); + + var json = JsonSerializer.Serialize(data); Assert.That( - IsValidJson(exceptionMessage), + IsValidJson(json), Is.True, - $"Exception message should be valid JSON, but got: {exceptionMessage}" + $"Serialized TrainExceptionData should be valid JSON: {json}" ); - // Verify that we can deserialize the exception message - var exceptionData = JsonSerializer.Deserialize(exceptionMessage); - Assert.That(exceptionData, Is.Not.Null); - Assert.That(exceptionData.Junction, Is.EqualTo("TestJunctionWithSpecialCharacters")); - Assert.That(exceptionData.Type, Is.EqualTo("InvalidOperationException")); - - // Verify that special characters are properly escaped - Assert.That(exceptionData.Message, Does.Contain("quotes")); - Assert.That(exceptionData.Message, Does.Contain("newlines")); - Assert.That(exceptionData.Message, Does.Contain("backslashes")); + // Round-trip: deserialize and verify the original JSON message survives + var roundTripped = JsonSerializer.Deserialize(json); + Assert.That(roundTripped, Is.Not.Null); + Assert.That(roundTripped!.Message, Does.Contain("\"success\":false")); } ///