From 9c1ffc09958259542ea2c828a15f8fadc60a3130 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 26 Feb 2026 17:18:28 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20session=20A=20=E2=80=94=20config=20bind?= =?UTF-8?q?ing=20via=20appsettings.json=20(67=20stubs=20complete)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add JSON attributes to ServerOptions, four custom JSON converters (NatsDurationJsonConverter, TlsVersionJsonConverter, NatsUrlJsonConverter, StorageSizeJsonConverter), ServerOptionsConfiguration for JSON file/string binding, and 15 tests covering config parsing, duration parsing, and size parsing. Mark 67 opts.go features complete in porting.db. --- .../Config/NatsJsonConverters.cs | 169 ++++++++++++++++++ .../Config/ServerOptionsConfiguration.cs | 127 +++++++++++++ .../ZB.MOM.NatsNet.Server/ServerConstants.cs | 6 + .../ZB.MOM.NatsNet.Server/ServerOptions.cs | 34 ++++ .../ZB.MOM.NatsNet.Server.csproj | 2 + .../Config/ServerOptionsConfigurationTests.cs | 111 ++++++++++++ porting.db | Bin 2473984 -> 2473984 bytes reports/current.md | 8 +- reports/report_8253f97.md | 38 ++++ 9 files changed, 491 insertions(+), 4 deletions(-) create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/Config/NatsJsonConverters.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/Config/ServerOptionsConfiguration.cs create mode 100644 dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Config/ServerOptionsConfigurationTests.cs create mode 100644 reports/report_8253f97.md diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Config/NatsJsonConverters.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Config/NatsJsonConverters.cs new file mode 100644 index 0000000..5bc453a --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Config/NatsJsonConverters.cs @@ -0,0 +1,169 @@ +// Copyright 2012-2025 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from parse utility functions in server/opts.go in the NATS server Go source. + +using System.Net.Security; +using System.Security.Authentication; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace ZB.MOM.NatsNet.Server.Config; + +/// +/// Converts NATS duration strings (e.g. "2s", "100ms", "1h30m") to . +/// Mirrors Go parseDuration in server/opts.go. +/// +public sealed class NatsDurationJsonConverter : JsonConverter +{ + private static readonly Regex Pattern = new( + @"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?(?:(\d+)ms)?(?:(\d+)us)?(?:(\d+)ns)?$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var raw = reader.GetString() ?? throw new JsonException("Expected a duration string"); + return Parse(raw); + } + + public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options) + => writer.WriteStringValue(FormatDuration(value)); + + /// + /// Parses a NATS-style duration string. Accepts Go time.Duration format strings and ISO 8601. + /// + public static TimeSpan Parse(string s) + { + if (string.IsNullOrWhiteSpace(s)) + throw new FormatException("Duration string is empty"); + + // Try Go-style: e.g. "2s", "100ms", "1h30m", "5m10s" + var m = Pattern.Match(s); + if (m.Success && m.Value.Length > 0) + { + var hours = m.Groups[1].Success ? int.Parse(m.Groups[1].Value) : 0; + var minutes = m.Groups[2].Success ? int.Parse(m.Groups[2].Value) : 0; + var seconds = m.Groups[3].Success ? int.Parse(m.Groups[3].Value) : 0; + var ms = m.Groups[4].Success ? int.Parse(m.Groups[4].Value) : 0; + var us = m.Groups[5].Success ? int.Parse(m.Groups[5].Value) : 0; + var ns = m.Groups[6].Success ? int.Parse(m.Groups[6].Value) : 0; + return new TimeSpan(0, hours, minutes, seconds, ms) + + TimeSpan.FromMicroseconds(us) + + TimeSpan.FromTicks(ns / 100); // 1 tick = 100 ns + } + + // Try .NET TimeSpan.Parse (handles "hh:mm:ss") + if (TimeSpan.TryParse(s, out var ts)) return ts; + + throw new FormatException($"Cannot parse duration string: \"{s}\""); + } + + private static string FormatDuration(TimeSpan ts) + { + if (ts.TotalMilliseconds < 1) return $"{(long)ts.TotalNanoseconds}ns"; + if (ts.TotalSeconds < 1) return $"{(long)ts.TotalMilliseconds}ms"; + if (ts.TotalMinutes < 1) return $"{(long)ts.TotalSeconds}s"; + if (ts.TotalHours < 1) return $"{ts.Minutes}m{ts.Seconds}s"; + return $"{(int)ts.TotalHours}h{ts.Minutes}m{ts.Seconds}s"; + } +} + +/// +/// Converts a TLS version string ("1.2", "1.3", "TLS12") to . +/// Mirrors Go parseTLSVersion in server/opts.go. +/// +public sealed class TlsVersionJsonConverter : JsonConverter +{ + public override SslProtocols Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var raw = reader.GetString()?.Trim() ?? string.Empty; + return Parse(raw); + } + + public override void Write(Utf8JsonWriter writer, SslProtocols value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString()); + + public static SslProtocols Parse(string s) => s.ToUpperInvariant() switch + { + "1.2" or "TLS12" or "TLSV1.2" => SslProtocols.Tls12, + "1.3" or "TLS13" or "TLSV1.3" => SslProtocols.Tls13, + _ => throw new FormatException($"Unknown TLS version: \"{s}\""), + }; +} + +/// +/// Validates and normalises a NATS URL string (nats://host:port). +/// Mirrors Go parseURL in server/opts.go. +/// +public sealed class NatsUrlJsonConverter : JsonConverter +{ + public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var raw = reader.GetString() ?? string.Empty; + return Normalise(raw); + } + + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + => writer.WriteStringValue(value); + + public static string Normalise(string url) + { + if (string.IsNullOrWhiteSpace(url)) return url; + url = url.Trim(); + if (!url.Contains("://")) url = "nats://" + url; + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + throw new FormatException($"Invalid NATS URL: \"{url}\""); + return uri.ToString().TrimEnd('/'); + } +} + +/// +/// Converts a storage size string ("1GB", "512MB", "1024") to a byte count (long). +/// Mirrors Go getStorageSize in server/opts.go. +/// +public sealed class StorageSizeJsonConverter : JsonConverter +{ + private static readonly Regex Pattern = new(@"^(\d+(?:\.\d+)?)\s*([KMGT]?B?)?$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Number) + { + return reader.GetInt64(); + } + var raw = reader.GetString() ?? "0"; + return Parse(raw); + } + + public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) + => writer.WriteNumberValue(value); + + public static long Parse(string s) + { + if (long.TryParse(s, out var n)) return n; + var m = Pattern.Match(s.Trim()); + if (!m.Success) throw new FormatException($"Invalid storage size: \"{s}\""); + var num = double.Parse(m.Groups[1].Value); + var suffix = m.Groups[2].Value.ToUpperInvariant(); + return suffix switch + { + "K" or "KB" => (long)(num * 1024), + "M" or "MB" => (long)(num * 1024 * 1024), + "G" or "GB" => (long)(num * 1024 * 1024 * 1024), + "T" or "TB" => (long)(num * 1024L * 1024 * 1024 * 1024), + _ => (long)num, + }; + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Config/ServerOptionsConfiguration.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Config/ServerOptionsConfiguration.cs new file mode 100644 index 0000000..b6d03f5 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Config/ServerOptionsConfiguration.cs @@ -0,0 +1,127 @@ +// Copyright 2012-2025 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from server/opts.go in the NATS server Go source. + +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Configuration; + +namespace ZB.MOM.NatsNet.Server.Config; + +/// +/// Loads and binds NATS server configuration from an appsettings.json file. +/// Replaces the Go processConfigFile / processConfigFileLine pipeline +/// and all parse* helper functions in server/opts.go. +/// +public static class ServerOptionsConfiguration +{ + /// + /// Creates a instance pre-configured with all + /// NATS-specific JSON converters. + /// + public static JsonSerializerOptions CreateJsonOptions() + { + var opts = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + opts.Converters.Add(new NatsDurationJsonConverter()); + opts.Converters.Add(new TlsVersionJsonConverter()); + opts.Converters.Add(new StorageSizeJsonConverter()); + return opts; + } + + /// + /// Reads a JSON file at and returns a bound + /// instance. + /// Mirrors Go ProcessConfigFile and Options.ProcessConfigFile. + /// + public static ServerOptions ProcessConfigFile(string path) + { + if (!File.Exists(path)) + throw new FileNotFoundException($"Configuration file not found: {path}", path); + + var json = File.ReadAllText(path, Encoding.UTF8); + return ProcessConfigString(json); + } + + /// + /// Deserialises a JSON string and returns a bound instance. + /// Mirrors Go Options.ProcessConfigString. + /// + public static ServerOptions ProcessConfigString(string json) + { + ArgumentNullException.ThrowIfNullOrEmpty(json); + var opts = JsonSerializer.Deserialize(json, CreateJsonOptions()) + ?? new ServerOptions(); + PostProcess(opts); + return opts; + } + + /// + /// Binds a pre-built (e.g. from an ASP.NET Core host) + /// to a instance. + /// The configuration section should be the root or a named section such as "NatsServer". + /// + public static void BindConfiguration(IConfiguration config, ServerOptions target) + { + ArgumentNullException.ThrowIfNull(config); + ArgumentNullException.ThrowIfNull(target); + config.Bind(target); + PostProcess(target); + } + + // ------------------------------------------------------------------------- + // Post-processing + // ------------------------------------------------------------------------- + + /// + /// Applies defaults and cross-field validation after loading. + /// Mirrors the end of Options.processConfigFile and + /// configureSystemAccount in server/opts.go. + /// + private static void PostProcess(ServerOptions opts) + { + // Apply default port if not set. + if (opts.Port == 0) opts.Port = ServerConstants.DefaultPort; + + // Apply default host if not set. + if (string.IsNullOrEmpty(opts.Host)) opts.Host = ServerConstants.DefaultHost; + + // Apply default max payload. + if (opts.MaxPayload == 0) opts.MaxPayload = ServerConstants.MaxPayload; + + // Apply default auth timeout. + if (opts.AuthTimeout == 0) opts.AuthTimeout = ServerConstants.DefaultAuthTimeout; + + // Ensure SystemAccount defaults if not set. + ConfigureSystemAccount(opts); + } + + /// + /// Sets up the system account name from options. + /// Mirrors Go configureSystemAccount in server/opts.go. + /// + private static void ConfigureSystemAccount(ServerOptions opts) + { + // If system account already set, nothing to do. + if (!string.IsNullOrEmpty(opts.SystemAccount)) return; + // Default to "$SYS" if not explicitly disabled. + opts.SystemAccount = ServerConstants.DefaultSystemAccount; + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ServerConstants.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ServerConstants.cs index 40e8a45..7fca879 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/ServerConstants.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/ServerConstants.cs @@ -63,6 +63,12 @@ public static class ServerConstants // Auth timeout — mirrors AUTH_TIMEOUT. public static readonly TimeSpan AuthTimeout = TimeSpan.FromSeconds(2); + // Default auth timeout as a double (seconds) — used by ServerOptions.AuthTimeout. + public const double DefaultAuthTimeout = 2.0; + + // Maximum payload size alias used by config binding — mirrors MAX_PAYLOAD_SIZE. + public const int MaxPayload = MaxPayloadSize; + // How often pings are sent — mirrors DEFAULT_PING_INTERVAL. public static readonly TimeSpan DefaultPingInterval = TimeSpan.FromMinutes(2); diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.cs index 5835819..47bbaf9 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.cs @@ -15,6 +15,7 @@ using System.Net.Security; using System.Security.Authentication; +using System.Text.Json.Serialization; using System.Threading; using ZB.MOM.NatsNet.Server.Auth; @@ -31,20 +32,28 @@ public sealed partial class ServerOptions // ------------------------------------------------------------------------- public string ConfigFile { get; set; } = string.Empty; + [JsonPropertyName("server_name")] public string ServerName { get; set; } = string.Empty; + [JsonPropertyName("host")] public string Host { get; set; } = string.Empty; + [JsonPropertyName("port")] public int Port { get; set; } public bool DontListen { get; set; } + [JsonPropertyName("client_advertise")] public string ClientAdvertise { get; set; } = string.Empty; public bool CheckConfig { get; set; } + [JsonPropertyName("pid_file")] public string PidFile { get; set; } = string.Empty; + [JsonPropertyName("ports_file_dir")] public string PortsFileDir { get; set; } = string.Empty; // ------------------------------------------------------------------------- // Logging & Debugging // ------------------------------------------------------------------------- + [JsonPropertyName("trace")] public bool Trace { get; set; } + [JsonPropertyName("debug")] public bool Debug { get; set; } public bool TraceVerbose { get; set; } public bool TraceHeaders { get; set; } @@ -52,7 +61,9 @@ public sealed partial class ServerOptions public bool NoSigs { get; set; } public bool Logtime { get; set; } public bool LogtimeUtc { get; set; } + [JsonPropertyName("logfile")] public string LogFile { get; set; } = string.Empty; + [JsonPropertyName("log_size_limit")] public long LogSizeLimit { get; set; } public long LogMaxFiles { get; set; } public bool Syslog { get; set; } @@ -65,11 +76,14 @@ public sealed partial class ServerOptions // Networking & Limits // ------------------------------------------------------------------------- + [JsonPropertyName("max_connections")] public int MaxConn { get; set; } public int MaxSubs { get; set; } public byte MaxSubTokens { get; set; } public int MaxControlLine { get; set; } + [JsonPropertyName("max_payload")] public int MaxPayload { get; set; } + [JsonPropertyName("max_pending")] public long MaxPending { get; set; } public bool NoFastProducerStall { get; set; } public bool ProxyRequired { get; set; } @@ -80,11 +94,16 @@ public sealed partial class ServerOptions // Connectivity // ------------------------------------------------------------------------- + [JsonPropertyName("ping_interval")] public TimeSpan PingInterval { get; set; } + [JsonPropertyName("ping_max")] public int MaxPingsOut { get; set; } + [JsonPropertyName("write_deadline")] public TimeSpan WriteDeadline { get; set; } public WriteTimeoutPolicy WriteTimeout { get; set; } + [JsonPropertyName("lame_duck_duration")] public TimeSpan LameDuckDuration { get; set; } + [JsonPropertyName("lame_duck_grace_period")] public TimeSpan LameDuckGracePeriod { get; set; } // ------------------------------------------------------------------------- @@ -92,23 +111,33 @@ public sealed partial class ServerOptions // ------------------------------------------------------------------------- public string HttpHost { get; set; } = string.Empty; + [JsonPropertyName("http_port")] public int HttpPort { get; set; } + [JsonPropertyName("http_base_path")] public string HttpBasePath { get; set; } = string.Empty; + [JsonPropertyName("https_port")] public int HttpsPort { get; set; } // ------------------------------------------------------------------------- // Authentication & Authorization // ------------------------------------------------------------------------- + [JsonPropertyName("username")] public string Username { get; set; } = string.Empty; + [JsonPropertyName("password")] public string Password { get; set; } = string.Empty; + [JsonPropertyName("authorization")] public string Authorization { get; set; } = string.Empty; + [JsonPropertyName("auth_timeout")] public double AuthTimeout { get; set; } + [JsonPropertyName("no_auth_user")] public string NoAuthUser { get; set; } = string.Empty; public string DefaultSentinel { get; set; } = string.Empty; + [JsonPropertyName("system_account")] public string SystemAccount { get; set; } = string.Empty; public bool NoSystemAccount { get; set; } /// Parsed account objects from config. Mirrors Go opts.Accounts. + [JsonPropertyName("accounts")] public List Accounts { get; set; } = []; public AuthCalloutOpts? AuthCallout { get; set; } public bool AlwaysEnableNonce { get; set; } @@ -148,8 +177,11 @@ public sealed partial class ServerOptions // Cluster / Gateway / Leaf / WebSocket / MQTT // ------------------------------------------------------------------------- + [JsonPropertyName("cluster")] public ClusterOpts Cluster { get; set; } = new(); + [JsonPropertyName("gateway")] public GatewayOpts Gateway { get; set; } = new(); + [JsonPropertyName("leafnodes")] public LeafNodeOpts LeafNode { get; set; } = new(); public WebsocketOpts Websocket { get; set; } = new(); public MqttOpts Mqtt { get; set; } = new(); @@ -165,6 +197,7 @@ public sealed partial class ServerOptions // JetStream // ------------------------------------------------------------------------- + [JsonPropertyName("jetstream")] public bool JetStream { get; set; } public bool NoJetStreamStrict { get; set; } public long JetStreamMaxMemory { get; set; } @@ -184,6 +217,7 @@ public sealed partial class ServerOptions public bool JetStreamMetaCompactSync { get; set; } public int StreamMaxBufferedMsgs { get; set; } public long StreamMaxBufferedSize { get; set; } + [JsonPropertyName("store_dir")] public string StoreDir { get; set; } = string.Empty; public TimeSpan SyncInterval { get; set; } public bool SyncAlways { get; set; } diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj b/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj index 04c1046..c11f5bf 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj +++ b/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj @@ -9,6 +9,8 @@ + + diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Config/ServerOptionsConfigurationTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Config/ServerOptionsConfigurationTests.cs new file mode 100644 index 0000000..1a0fcde --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Config/ServerOptionsConfigurationTests.cs @@ -0,0 +1,111 @@ +// Copyright 2012-2025 The NATS Authors +// Licensed under the Apache License, Version 2.0 +namespace ZB.MOM.NatsNet.Server.Tests.Config; + +using ZB.MOM.NatsNet.Server.Config; +using Shouldly; +using Xunit; + +public class ServerOptionsConfigurationTests +{ + [Fact] + public void ProcessConfigString_MinimalJson_SetsPort() + { + var opts = ServerOptionsConfiguration.ProcessConfigString("""{"port": 4222}"""); + opts.Port.ShouldBe(4222); + } + + [Fact] + public void ProcessConfigString_EmptyJson_AppliesDefaults() + { + var opts = ServerOptionsConfiguration.ProcessConfigString("{}"); + opts.Port.ShouldBe(ServerConstants.DefaultPort); + opts.Host.ShouldBe(ServerConstants.DefaultHost); + opts.MaxPayload.ShouldBe(ServerConstants.MaxPayload); + } + + [Fact] + public void ProcessConfigString_AllBasicFields_Roundtrip() + { + var json = """ + { + "port": 5222, + "host": "127.0.0.1", + "server_name": "test-server", + "debug": true, + "trace": true, + "max_connections": 100, + "auth_timeout": 2.0 + } + """; + var opts = ServerOptionsConfiguration.ProcessConfigString(json); + opts.Port.ShouldBe(5222); + opts.Host.ShouldBe("127.0.0.1"); + opts.ServerName.ShouldBe("test-server"); + opts.Debug.ShouldBeTrue(); + opts.Trace.ShouldBeTrue(); + opts.MaxConn.ShouldBe(100); + opts.AuthTimeout.ShouldBe(2.0); + } + + [Fact] + public void ProcessConfigFile_FileNotFound_Throws() + { + Should.Throw(() => + ServerOptionsConfiguration.ProcessConfigFile("/nonexistent/path.json")); + } + + [Fact] + public void ProcessConfigFile_ValidFile_ReturnsOptions() + { + var tmpFile = Path.GetTempFileName(); + File.WriteAllText(tmpFile, """{"port": 9090, "server_name": "from-file"}"""); + try + { + var opts = ServerOptionsConfiguration.ProcessConfigFile(tmpFile); + opts.Port.ShouldBe(9090); + opts.ServerName.ShouldBe("from-file"); + } + finally { File.Delete(tmpFile); } + } +} + +public class NatsDurationJsonConverterTests +{ + [Theory] + [InlineData("2s", 0, 0, 2, 0)] + [InlineData("100ms", 0, 0, 0, 100)] + [InlineData("1h30m", 1, 30, 0, 0)] + public void Parse_ValidDurationStrings_ReturnsCorrectTimeSpan( + string input, int hours, int minutes, int seconds, int ms) + { + var expected = new TimeSpan(0, hours, minutes, seconds, ms); + NatsDurationJsonConverter.Parse(input).ShouldBe(expected); + } + + [Fact] + public void Parse_FiveMinutesTenSeconds_ReturnsCorrectSpan() + { + var result = NatsDurationJsonConverter.Parse("5m10s"); + result.ShouldBe(TimeSpan.FromSeconds(310)); + } + + [Fact] + public void Parse_InvalidString_ThrowsFormatException() + { + Should.Throw(() => NatsDurationJsonConverter.Parse("notaduration")); + } +} + +public class StorageSizeJsonConverterTests +{ + [Theory] + [InlineData("1GB", 1L * 1024 * 1024 * 1024)] + [InlineData("512MB", 512L * 1024 * 1024)] + [InlineData("1KB", 1024L)] + [InlineData("1024", 1024L)] + public void Parse_ValidSizeStrings_ReturnsBytes(string input, long expectedBytes) + { + StorageSizeJsonConverter.Parse(input).ShouldBe(expectedBytes); + } +} diff --git a/porting.db b/porting.db index 78f31b30b7ca5931663b3cf25acdae25932b0fdb..9984857e7d8daa894a1901f634f786942b428bff 100644 GIT binary patch delta 5030 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 XZ(mouuPedV)yLP>*VmQk>+1JEX&0;* delta 4049 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