diff --git a/src/ServiceControl.Audit.UnitTests/SagaAudit/CustomTimeSpanConverterTests.cs b/src/ServiceControl.Audit.UnitTests/SagaAudit/CustomTimeSpanConverterTests.cs new file mode 100644 index 0000000000..193787248c --- /dev/null +++ b/src/ServiceControl.Audit.UnitTests/SagaAudit/CustomTimeSpanConverterTests.cs @@ -0,0 +1,108 @@ +namespace ServiceControl.Audit.UnitTests.SagaAudit +{ + using NUnit.Framework; + using ServiceControl.SagaAudit; + using JsonSerializer = System.Text.Json.JsonSerializer; + + [TestFixture] + public class CustomTimeSpanConverterTests + { + [Test] + // Days, Hours, Minutes, and Seconds + [TestCase("1:0:00:00")] // 1 day, 0 hours, 0 minutes, 0 seconds + [TestCase("2:0:00:00")] // 2 days, 0 hours, 0 minutes, 0 seconds + [TestCase("3:5:6:7.890")] // 3 days, 5 hours, 6 minutes, 7 seconds, with milliseconds + [TestCase("1:02:03")] // 1 hour, 2 minutes, 3 seconds + [TestCase("0:00:00")] // Zero TimeSpan + [TestCase("1.0:00:00")] // 1 day, 0 hours, 0 minutes, 0 seconds + [TestCase("1.23:59:59")] // 1 day, 23 hours, 59 minutes, 59 seconds + [TestCase("10.12:30:45")] // 10 days, 12 hours, 30 minutes, 45 seconds + + // Milliseconds and Ticks + [TestCase("0:00:00.123")] // 123 milliseconds + [TestCase("0:00:00.9999999")] // 9999999 ticks + [TestCase("0:00:00.1")] // 1 tenth of a second + [TestCase("1.0:00:00.1234567")] // 1 day, 0 hours, 0 minutes, 0 seconds, with fractional seconds + + // Single Time Components + [TestCase("1:00:00")] // 1 hour, 0 minutes, 0 seconds + [TestCase("1:2:3")] // 1 hour, 2 minutes, 3 seconds without leading zeros + [TestCase("0:1:1")] // Zero hours with 1 minute and 1 second + [TestCase("1:1:1.123")] // 1 hour, 1 minute, 1 second with milliseconds + [TestCase("0:0:0.1")] // 1 tenth of a second + [TestCase("1:2:3.456")] // 1 hour, 2 minutes, 3 seconds with milliseconds + [TestCase("12:34:56.789")] // 12 hours, 34 minutes, 56 seconds with milliseconds + [TestCase("0:59:59")] // 59 minutes, 59 seconds + [TestCase("0:0:0.999")] // 999 milliseconds + [TestCase("6:30:0")] // 6 hours, 30 minutes + + // Whole Days + [TestCase("1")] // 1 day + [TestCase("10")] // 10 days + + // Minutes and Seconds + [TestCase("00:01")] // 1 minute + [TestCase("0:00:02")] // 2 seconds + + // Small Fractions of a Second + [TestCase("0:00:00.0000001")] // 1 tick + [TestCase("0:00:00.0000010")] // 10 ticks + [TestCase("0:00:00.0000100")] // 100 ticks + [TestCase("0:00:00.0001000")] // 1000 ticks + [TestCase("0:00:00.0010000")] // 10000 ticks (1 millisecond) + [TestCase("0:00:00.0100000")] // 100000 ticks (10 milliseconds) + [TestCase("0:00:00.1000000")] // 1000000 ticks (100 milliseconds) + + // Large Time Values & Special chars + [TestCase("23:59:59")] // 23 hours, 59 minutes, 59 seconds + [TestCase("\\u002D23:59:59")] // -23 hours, 59 minutes, 59 seconds (Unicode escape for minus sign) + [TestCase( + "\\u0032\\u0033\\u003A\\u0035\\u0039\\u003A\\u0035\\u0039")] // "23:59:59" with Unicode escape sequences for digits and colon + [TestCase("23:59:59.9")] // 23 hours, 59 minutes, 59 seconds, with 900 milliseconds + [TestCase("23:59:59.9999999")] // 23 hours, 59 minutes, 59 seconds, with 9999999 ticks + [TestCase("9999999.23:59:59.9999999")] // 9999999 days, 23 hours, 59 minutes, 59 seconds, with 9999999 ticks + [TestCase("-9999999.23:59:59.9999999")] // -9999999 days, 23 hours, 59 minutes, 59 seconds, with 9999999 ticks + + // Max and Min TimeSpan Values + [TestCase("10675199.02:48:05.4775807")] // TimeSpan.MaxValue + [TestCase("-10675199.02:48:05.4775808")] // TimeSpan.MinValue + public void Should_not_throw_on_valid_timespans(string timespan) + { + string json = $$""" + { + "ResultingMessages": [ + { + "DeliveryAt": null, + "DeliveryDelay": "{{timespan}}" + } + ] + } + """; + + Assert.DoesNotThrow(() => + { + JsonSerializer.Deserialize(json, SagaAuditMessagesSerializationContext.Default.SagaUpdatedMessage); + }); + } + + [Test] + public void Should_not_throw_on_null() + { + string json = """ + { + "ResultingMessages": [ + { + "DeliveryAt": null, + "DeliveryDelay":null + } + ] + } + """; + + Assert.DoesNotThrow(() => + { + JsonSerializer.Deserialize(json, SagaAuditMessagesSerializationContext.Default.SagaUpdatedMessage); + }); + } + } +} \ No newline at end of file diff --git a/src/ServiceControl.SagaAudit/CustomTimeSpanConverter.cs b/src/ServiceControl.SagaAudit/CustomTimeSpanConverter.cs index 5602e61a15..87b3efd1bb 100644 --- a/src/ServiceControl.SagaAudit/CustomTimeSpanConverter.cs +++ b/src/ServiceControl.SagaAudit/CustomTimeSpanConverter.cs @@ -18,8 +18,7 @@ namespace ServiceControl.SagaAudit /// Using this code outside this specific use case here is probably a very bad idea. Be warned. sealed class CustomTimeSpanConverter : JsonConverter { - // we allow the short format "g" too which has a minimum of 7 chars. .NET Requires min 8 chars - const int MinimumTimeSpanFormatLength = 7; // hh:mm:ss or h:mm:ss + const int MinimumTimeSpanFormatLength = 1; // d const int MaximumTimeSpanFormatLength = 26; // -dddddddd.hh:mm:ss.fffffff const int MaxExpansionFactorWhileEscaping = 6; @@ -57,8 +56,10 @@ public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, Jso ThrowFormatException(); } - // Ut8Parser.TryParse also handles short format "g" which has a minimum of 7 chars independent of the format identifier - if (!Utf8Parser.TryParse(source, out TimeSpan parsedTimeSpan, out int bytesConsumed, 'c') || source.Length != bytesConsumed) + // Ut8Parser.TryParse also handles some short format "g" cases which has a minimum of 1 chars independent of the format identifier + if ((!Utf8Parser.TryParse(source, out TimeSpan parsedTimeSpan, out int bytesConsumed, 'c') || source.Length != bytesConsumed) && + // Otherwise we fall back to read with the short format "g" directly since that is what the SagaAudit plugin used to stay backward compatible + (!Utf8Parser.TryParse(source, out parsedTimeSpan, out bytesConsumed, 'g') || source.Length != bytesConsumed)) { ThrowFormatException(); }