From 91292f99213c3eef1e8f01f99124902623e5346e Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 7 Oct 2025 20:25:13 +0100 Subject: [PATCH 01/12] :white_check_mark: add strict depth overflow tests and hashtable conversion validation --- QsNet.Tests/DecodeTests.cs | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/QsNet.Tests/DecodeTests.cs b/QsNet.Tests/DecodeTests.cs index 861ef04..e1125e9 100644 --- a/QsNet.Tests/DecodeTests.cs +++ b/QsNet.Tests/DecodeTests.cs @@ -27,6 +27,38 @@ public void Decode_ThrowsArgumentException_WhenParameterLimitIsNotPositive() exception.Message.Should().Contain("Parameter limit must be a positive integer."); } + [Fact] + public void Decode_StrictDepthOverflowThrows() + { + var options = new DecodeOptions { Depth = 1, StrictDepth = true }; + Action act = () => Qs.Decode("a[b][c]=1", options); + act.Should() + .Throw() + .WithMessage("Input depth exceeded depth option of 1 and strictDepth is true"); + } + + [Fact] + public void SplitKeyIntoSegments_StrictDepthThrowsOnTrailingText() + { + Action act = () => InternalDecoder.SplitKeyIntoSegments("a[b]c", false, 1, true); + act.Should() + .Throw() + .WithMessage("Input depth exceeded depth option of 1 and strictDepth is true"); + } + + [Fact] + public void ParseKeys_ConvertsHashtableLeafToObjectKeyedDictionary() + { + var hashtable = new Hashtable { ["inner"] = "value" }; + var result = InternalDecoder.ParseKeys("root", hashtable, new DecodeOptions(), false); + + result.Should().BeOfType>(); + var dict = (Dictionary)result!; + dict.Should().ContainKey("root"); + dict["root"].Should().BeOfType>() + .Which.Should().ContainKey("inner").WhoseValue.Should().Be("value"); + } + [Fact] public void Decode_NestedListHandling_InParseObjectMethod() { @@ -4783,4 +4815,4 @@ public void Decode_CommaSplit_ThrowsWhenSumExceedsLimitAndThrowOn() } #endregion -} \ No newline at end of file +} From 467955cde927c128ad77e1f06e1d7016654a65e4 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 7 Oct 2025 20:25:23 +0100 Subject: [PATCH 02/12] :white_check_mark: add tests for encoding various data types and structures --- QsNet.Tests/EncodeTests.cs | 44 +++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/QsNet.Tests/EncodeTests.cs b/QsNet.Tests/EncodeTests.cs index 4fa17d6..dd80f7f 100644 --- a/QsNet.Tests/EncodeTests.cs +++ b/QsNet.Tests/EncodeTests.cs @@ -199,6 +199,48 @@ public void Encode_EncodesFalsyValues() Qs.Encode(0).Should().Be(""); } + [Fact] + public void Encode_PrimitiveBoolWithoutEncoder_UsesLiteralValues() + { + var options = new EncodeOptions { Encode = false }; + var result = Qs.Encode(new Dictionary { { "flag", true } }, options); + result.Should().Be("flag=true"); + } + + [Fact] + public void Encode_NonListEnumerableMaterializesIndices() + { + var queue = new Queue(new[] { "a", "b" }); + var result = Qs.Encode(new Dictionary { { "queue", queue } }, new EncodeOptions { Encode = false }); + result.Should().Be("queue[0]=a&queue[1]=b"); + } + + [Fact] + public void Encode_IterableFilterAllowsConvertibleIndices() + { + var list = new List { "zero", "one", "two" }; + var encoded = Encoder.Encode( + list, + undefined: false, + sideChannel: new SideChannelFrame(), + prefix: "items", + filter: new IterableFilter(new object[] { "1", "missing" }) + ); + + encoded.Should().BeOfType>(); + var parts = ((List)encoded).Select(v => v?.ToString()).Where(s => !string.IsNullOrEmpty(s)).ToList(); + parts.Should().Contain("items[1]=one"); + parts.Should().HaveCount(1); + } + + [Fact] + public void Encode_AcceptsNonGenericDictionary() + { + var map = new Hashtable { { "a", "b" }, { 1, "one" } }; + var result = Qs.Encode(map, new EncodeOptions { Encode = false }); + result.Split('&').Should().BeEquivalentTo(new[] { "a=b", "1=one" }); + } + [Fact] public void Encode_EncodesLongs() { @@ -4627,4 +4669,4 @@ public class CustomObject(string value) { public string this[string key] => key == "prop" ? value : throw new KeyNotFoundException($"Key '{key}' not found"); -} \ No newline at end of file +} From 3f07f72f26582a90a6a81e4d79c29e06a65f4bb1 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 7 Oct 2025 20:25:32 +0100 Subject: [PATCH 03/12] :white_check_mark: add unit tests for Utils.Merge, Utils.Encode, and Utils.ConvertNestedDictionary methods --- QsNet.Tests/UtilsTests.cs | 128 +++++++++++++++++++++++++++++++++++++- 1 file changed, 127 insertions(+), 1 deletion(-) diff --git a/QsNet.Tests/UtilsTests.cs b/QsNet.Tests/UtilsTests.cs index cc4d12d..2e2cf33 100644 --- a/QsNet.Tests/UtilsTests.cs +++ b/QsNet.Tests/UtilsTests.cs @@ -2055,5 +2055,131 @@ public void ConvertDictionaryToStringKeyed_Converts_NonGeneric_Keys_To_Strings() res.Should().Equal(new Dictionary { ["1"] = "x", ["y"] = 2 }); } + [Fact] + public void Merge_ReturnsTargetWhenSourceIsNull() + { + var target = new Dictionary { ["a"] = "b" }; + var result = Utils.Merge(target, null); + result.Should().BeSameAs(target); + } + + [Fact] + public void Merge_RemovesUndefinedEntriesWhenListsAreDisabled() + { + var undefined = Undefined.Create(); + var target = new List { undefined, "keep" }; + var options = new DecodeOptions { ParseLists = false }; + + var result = Utils.Merge(target, Undefined.Create(), options); + + result.Should().BeEquivalentTo(new List { "keep" }); + } + + [Fact] + public void Encode_UnpairedSurrogateFallsBackToThreeByteSequence() + { + var highSurrogate = "\uD83D"; // lone high surrogate, invalid pair + var encoded = Utils.Encode(highSurrogate); + encoded.Should().Be("%ED%A0%BD"); + } + + [Fact] + public void Compact_ConvertsNestedNonGenericDictionariesInsideStringMaps() + { + var inner = new Hashtable { ["x"] = 1 }; + var stringKeyed = new Dictionary { ["inner"] = inner, ["skip"] = Undefined.Create() }; + var root = new Dictionary { ["root"] = stringKeyed }; + + var compacted = Utils.Compact(root); + + compacted.Should().ContainKey("root"); + var converted = compacted["root"].Should().BeOfType>().Which; + converted.Should().NotContainKey("skip"); + converted["inner"].Should().BeOfType>(); + } + + [Fact] + public void ConvertNestedDictionary_HandlesCyclicReferencesWithoutReentry() + { + IDictionary first = new Hashtable(); + IDictionary second = new Hashtable(); + first["next"] = second; + second["back"] = first; + + var converted = Utils.ConvertNestedDictionary(first); + + converted.Should().ContainKey("next"); + var next = converted["next"].Should().BeOfType>().Which; + next.Should().ContainKey("back"); + var back = next["back"].Should().BeOfType>().Which; + back.Should().ContainKey("next"); + back["next"].Should().BeAssignableTo(); + } + + [Fact] + public void Decode_InvalidPercentEncodingFallsBackToOriginal() + { + var original = "%E0%A4"; + var strictEncoding = Encoding.GetEncoding( + "utf-8", + new EncoderExceptionFallback(), + new DecoderExceptionFallback() + ); + + Utils.Decode(original, strictEncoding).Should().Be(original); + } + + [Fact] + public void InterpretNumericEntities_InvalidDigitsLeaveSequenceUntouched() + { + var input = "&#xyz;"; + Utils.InterpretNumericEntities(input).Should().Be(input); + } + + [Fact] + public void ToStringKeyDeepNonRecursive_ThrowsForNonDictionaryRoot() + { + Action act = () => Utils.ToStringKeyDeepNonRecursive(new object()); + act.Should().Throw().WithMessage("*Root must be an IDictionary*"); + } + + [Fact] + public void ConvertNestedDictionary_PreservesSelfReferencesAndStringKeyedMaps() + { + IDictionary parent = new Hashtable(); + parent["self"] = parent; + var stringChild = new Dictionary { ["x"] = 1 }; + parent["string"] = stringChild; + + var converted = Utils.ConvertNestedDictionary(parent); + + converted["self"].Should().BeSameAs(parent); + converted["string"].Should().BeSameAs(stringChild); + } + + [Fact] + public void ToStringKeyDeepNonRecursive_ReusesVisitedNodesInLists() + { + IDictionary shared = new Hashtable { ["v"] = 1 }; + IList list = new ArrayList { shared, shared }; + IDictionary root = new Hashtable { ["list"] = list }; + + var result = Utils.ToStringKeyDeepNonRecursive(root); + var convertedList = result["list"].Should().BeOfType>().Which; + convertedList[0].Should().BeSameAs(convertedList[1]); + } + + [Fact] + public void ToStringKeyDeepNonRecursive_SupportsSelfReferentialLists() + { + IList inner = new ArrayList(); + inner.Add(inner); + IDictionary root = new Hashtable { ["loop"] = inner }; + + var result = Utils.ToStringKeyDeepNonRecursive(root); + var convertedList = result["loop"].Should().BeOfType>().Which; + convertedList[0].Should().BeSameAs(convertedList); + } + #endregion -} \ No newline at end of file +} From fcbee1aa418a28932c0dcd9599c652e54c19a637 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 7 Oct 2025 20:44:20 +0100 Subject: [PATCH 04/12] :white_check_mark: add test for SplitKeyIntoSegments method to validate trailing segment behavior --- QsNet.Tests/DecodeTests.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/QsNet.Tests/DecodeTests.cs b/QsNet.Tests/DecodeTests.cs index e1125e9..d226751 100644 --- a/QsNet.Tests/DecodeTests.cs +++ b/QsNet.Tests/DecodeTests.cs @@ -46,6 +46,13 @@ public void SplitKeyIntoSegments_StrictDepthThrowsOnTrailingText() .WithMessage("Input depth exceeded depth option of 1 and strictDepth is true"); } + [Fact] + public void SplitKeyIntoSegments_AppendsTrailingSegmentWhenNotStrict() + { + var segments = InternalDecoder.SplitKeyIntoSegments("a[b]c", false, 2, false); + segments.Should().Contain("[c]"); + } + [Fact] public void ParseKeys_ConvertsHashtableLeafToObjectKeyedDictionary() { From 99d917765895030df7001e1c4a7d8e5f55d839d8 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 7 Oct 2025 20:44:33 +0100 Subject: [PATCH 05/12] :white_check_mark: add tests for encoding generic interface dictionaries and handling filter exceptions --- QsNet.Tests/EncodeTests.cs | 59 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/QsNet.Tests/EncodeTests.cs b/QsNet.Tests/EncodeTests.cs index dd80f7f..ed7e2d4 100644 --- a/QsNet.Tests/EncodeTests.cs +++ b/QsNet.Tests/EncodeTests.cs @@ -241,6 +241,65 @@ public void Encode_AcceptsNonGenericDictionary() result.Split('&').Should().BeEquivalentTo(new[] { "a=b", "1=one" }); } + [Fact] + public void Encode_CopiesGenericInterfaceDictionary() + { + IDictionary sorted = new SortedList { ["b"] = 2, ["a"] = 1 }; + + var encoded = Qs.Encode(sorted, new EncodeOptions { Encode = false }); + + encoded.Should().Be("a=1&b=2"); + } + + [Fact] + public void Encode_FilterExceptionsAreIgnored() + { + var data = new Dictionary { ["a"] = 1 }; + var options = new EncodeOptions + { + Encode = false, + Filter = new FunctionFilter((key, value) => + { + if (key.Length == 0) throw new InvalidOperationException(); + return value; + }) + }; + + Qs.Encode(data, options).Should().Be("a=1"); + } + + [Fact] + public void Encode_SkipNullsSkipsMissingFilteredKeys() + { + var data = new Dictionary { ["present"] = "value" }; + var options = new EncodeOptions + { + Encode = false, + SkipNulls = true, + Filter = new IterableFilter(new object[] { "present", "missing" }) + }; + + Qs.Encode(data, options).Should().Be("present=value"); + } + + [Fact] + public void Encoder_TreatsOutOfRangeIterableIndicesAsUndefined() + { + var list = new List { "zero", "one" }; + var encoded = Encoder.Encode( + list, + undefined: false, + sideChannel: new SideChannelFrame(), + prefix: "items", + filter: new IterableFilter(new object[] { "0", "5" }) + ); + + encoded.Should().BeOfType>(); + var parts = ((List)encoded).Select(x => x?.ToString()).ToList(); + parts.Should().Contain("items[0]=zero"); + parts.Should().NotContain(s => s != null && s.Contains("items[5]")); + } + [Fact] public void Encode_EncodesLongs() { From d7786c566d23806f9a2bc75baff0255070b683a9 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 7 Oct 2025 20:45:20 +0100 Subject: [PATCH 06/12] :white_check_mark: add tests for InterpretNumericEntities overflow handling and Compact method behavior --- QsNet.Tests/UtilsTests.cs | 68 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/QsNet.Tests/UtilsTests.cs b/QsNet.Tests/UtilsTests.cs index 2e2cf33..ae057a8 100644 --- a/QsNet.Tests/UtilsTests.cs +++ b/QsNet.Tests/UtilsTests.cs @@ -2136,6 +2136,13 @@ public void InterpretNumericEntities_InvalidDigitsLeaveSequenceUntouched() Utils.InterpretNumericEntities(input).Should().Be(input); } + [Fact] + public void InterpretNumericEntities_OverflowDigitsAreLeftAlone() + { + var input = "�"; + Utils.InterpretNumericEntities(input).Should().Be(input); + } + [Fact] public void ToStringKeyDeepNonRecursive_ThrowsForNonDictionaryRoot() { @@ -2157,6 +2164,31 @@ public void ConvertNestedDictionary_PreservesSelfReferencesAndStringKeyedMaps() converted["string"].Should().BeSameAs(stringChild); } + [Fact] + public void Compact_VisitsStringKeyedDictionaries() + { + var child = new Dictionary { ["value"] = 1 }; + var root = new Dictionary { ["child"] = child }; + + var result = Utils.Compact(root); + + result.Should().ContainKey("child"); + result["child"].Should().BeOfType>().Which.Should().ContainKey("value"); + } + + [Fact] + public void Compact_ConvertsNestedNonGenericDictionaryWithinStringDictionary() + { + var inner = new Hashtable { ["x"] = 1 }; + var map = new Dictionary { ["inner"] = inner }; + var root = new Dictionary { ["outer"] = map }; + + var compacted = Utils.Compact(root); + + var converted = compacted["outer"].Should().BeOfType>().Which; + converted["inner"].Should().BeOfType>(); + } + [Fact] public void ToStringKeyDeepNonRecursive_ReusesVisitedNodesInLists() { @@ -2169,6 +2201,18 @@ public void ToStringKeyDeepNonRecursive_ReusesVisitedNodesInLists() convertedList[0].Should().BeSameAs(convertedList[1]); } + [Fact] + public void ToStringKeyDeepNonRecursive_ReusesListsReferencedMultipleTimes() + { + IList shared = new ArrayList { 1 }; + IDictionary root = new Hashtable { ["a"] = shared, ["b"] = shared }; + + var result = Utils.ToStringKeyDeepNonRecursive(root); + var first = result["a"].Should().BeOfType>().Which; + var second = result["b"].Should().BeOfType>().Which; + second.Should().BeSameAs(first); + } + [Fact] public void ToStringKeyDeepNonRecursive_SupportsSelfReferentialLists() { @@ -2181,5 +2225,29 @@ public void ToStringKeyDeepNonRecursive_SupportsSelfReferentialLists() convertedList[0].Should().BeSameAs(convertedList); } + [Fact] + public void ConvertNestedDictionary_ReturnsExistingStringKeyedInstanceWhenVisited() + { + var method = typeof(Utils) + .GetMethod( + "ConvertNestedDictionary", + BindingFlags.NonPublic | BindingFlags.Static, + null, + new[] { typeof(IDictionary), typeof(ISet) }, + null + ); + method.Should().NotBeNull(); + + var convert = method!; + + var dictionary = new Dictionary { ["x"] = 1 }; + IDictionary raw = dictionary; + var visited = new HashSet(QsNet.Internal.ReferenceEqualityComparer.Instance); + visited.Add(raw); + + var result = (Dictionary)convert.Invoke(null, new object[] { raw, visited })!; + result.Should().BeSameAs(dictionary); + } + #endregion } From 47240438b31c08529c5016da8b3a6c79abce6289 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 7 Oct 2025 20:59:35 +0100 Subject: [PATCH 07/12] :white_check_mark: add tests for Decode method and refactor test cases for clarity --- QsNet.Tests/DecodeTests.cs | 43 +++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/QsNet.Tests/DecodeTests.cs b/QsNet.Tests/DecodeTests.cs index d226751..37a3b4a 100644 --- a/QsNet.Tests/DecodeTests.cs +++ b/QsNet.Tests/DecodeTests.cs @@ -16,7 +16,7 @@ namespace QsNet.Tests; -public class DecodeTest +public partial class DecodeTest { [Fact] public void Decode_ThrowsArgumentException_WhenParameterLimitIsNotPositive() @@ -60,7 +60,7 @@ public void ParseKeys_ConvertsHashtableLeafToObjectKeyedDictionary() var result = InternalDecoder.ParseKeys("root", hashtable, new DecodeOptions(), false); result.Should().BeOfType>(); - var dict = (Dictionary)result!; + var dict = (Dictionary)result; dict.Should().ContainKey("root"); dict["root"].Should().BeOfType>() .Which.Should().ContainKey("inner").WhoseValue.Should().Be("value"); @@ -101,7 +101,7 @@ public void Decode_NestedListHandling_InParseObjectMethod() // Try a more complex approach that should trigger the specific code path // First, create a query string that will create a list with a nested list - var queryString3 = "a[0][]=first&a[0][]=second"; + const string queryString3 = "a[0][]=first&a[0][]=second"; // Now decode it, which should create a list with a nested list var result3 = Qs.Decode(queryString3); @@ -117,7 +117,7 @@ public void Decode_NestedListHandling_InParseObjectMethod() result3.Should().BeEquivalentTo(expected3); // Now try to add to the existing list - var queryString4 = "a[0][2]=third"; + const string queryString4 = "a[0][2]=third"; // Decode it with the existing result as the input var result4 = Qs.Decode(queryString4); @@ -1434,8 +1434,7 @@ public void Decode_ParsesBuffersCorrectly() [Fact] public void Decode_ParsesJqueryParamStrings() { - var encoded = - "filter%5B0%5D%5B%5D=int1&filter%5B0%5D%5B%5D=%3D&filter%5B0%5D%5B%5D=77&filter%5B%5D=and&filter%5B2%5D%5B%5D=int2&filter%5B2%5D%5B%5D=%3D&filter%5B2%5D%5B%5D=8"; + const string encoded = "filter%5B0%5D%5B%5D=int1&filter%5B0%5D%5B%5D=%3D&filter%5B0%5D%5B%5D=77&filter%5B%5D=and&filter%5B2%5D%5B%5D=int2&filter%5B2%5D%5B%5D=%3D&filter%5B2%5D%5B%5D=8"; var expected = new Dictionary { ["filter"] = new List @@ -1490,7 +1489,7 @@ public void Decode_ParsesStringWithAlternativeStringDelimiter() [Fact] public void Decode_ParsesStringWithAlternativeRegexDelimiter() { - Qs.Decode("a=b; c=d", new DecodeOptions { Delimiter = new RegexDelimiter(@"[;,] *") }) + Qs.Decode("a=b; c=d", new DecodeOptions { Delimiter = new RegexDelimiter("[;,] *") }) .Should() .BeEquivalentTo(new Dictionary { ["a"] = "b", ["c"] = "d" }); } @@ -1958,7 +1957,7 @@ public void Decode_ParsesDatesCorrectly() [Fact] public void Decode_ParsesRegularExpressionsCorrectly() { - var re = new Regex("^test$"); + var re = MyRegex(); Qs.Decode(new Dictionary { ["a"] = re }) .Should() .BeEquivalentTo(new Dictionary { ["a"] = re }); @@ -2034,13 +2033,14 @@ public void Decode_CanParseWithCustomEncoding() { var expected = new Dictionary { ["県"] = "大阪府" }; + var options = new DecodeOptions { Decoder = CustomDecoder }; + Qs.Decode("%8c%a7=%91%e5%8d%e3%95%7b", options).Should().BeEquivalentTo(expected); + return; + string? CustomDecoder(string? str, Encoding? charset) { return str?.Replace("%8c%a7", "県").Replace("%91%e5%8d%e3%95%7b", "大阪府"); } - - var options = new DecodeOptions { Decoder = CustomDecoder }; - Qs.Decode("%8c%a7=%91%e5%8d%e3%95%7b", options).Should().BeEquivalentTo(expected); } [Fact] @@ -2174,11 +2174,6 @@ public void Decode_HandlesCustomDecoderReturningNullInIso88591CharsetWhenInterpr { const string urlEncodedNumSmiley = "%26%239786%3B"; - string? CustomDecoder(string? str, Encoding? charset) - { - return !string.IsNullOrEmpty(str) ? Utils.Decode(str, charset) : null; - } - var options = new DecodeOptions { Charset = Encoding.Latin1, @@ -2189,6 +2184,12 @@ public void Decode_HandlesCustomDecoderReturningNullInIso88591CharsetWhenInterpr Qs.Decode($"foo=&bar={urlEncodedNumSmiley}", options) .Should() .BeEquivalentTo(new Dictionary { ["foo"] = null, ["bar"] = "☺" }); + return; + + string? CustomDecoder(string? str, Encoding? charset) + { + return !string.IsNullOrEmpty(str) ? Utils.Decode(str, charset) : null; + } } [Fact] @@ -3856,12 +3857,7 @@ public void ShouldUseNumberDecoder() { var options = new DecodeOptions { - Decoder = (value, _) => - { - if (int.TryParse(value, out var intValue)) - return $"[{intValue}]"; - return value; - } + Decoder = (value, _) => int.TryParse(value, out var intValue) ? $"[{intValue}]" : value }; Qs.Decode("foo=1", options) @@ -4821,5 +4817,8 @@ public void Decode_CommaSplit_ThrowsWhenSumExceedsLimitAndThrowOn() act.Should().Throw(); } + [GeneratedRegex("^test$")] + private static partial Regex MyRegex(); + #endregion } From f2d7fc845812dc50e6881eae7a4f8c077732a01c Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 7 Oct 2025 20:59:40 +0100 Subject: [PATCH 08/12] :white_check_mark: add tests for Encode method and refactor test cases for improved readability --- QsNet.Tests/EncodeTests.cs | 79 ++++++++++++++++++++------------------ 1 file changed, 42 insertions(+), 37 deletions(-) diff --git a/QsNet.Tests/EncodeTests.cs b/QsNet.Tests/EncodeTests.cs index ed7e2d4..3b7cb08 100644 --- a/QsNet.Tests/EncodeTests.cs +++ b/QsNet.Tests/EncodeTests.cs @@ -18,6 +18,12 @@ namespace QsNet.Tests; [TestSubject(typeof(Encoder))] public class EncodeTests { + private static readonly string[] Value = ["b", "c"]; + private static readonly string[] ValueArray = ["b", "c"]; + private static readonly string[] ValueArray0 = ["b"]; + private static readonly string[] Iterable = ["a"]; + private static readonly string[] Value1 = ["b"]; + [Fact] public void Encode_DefaultParameterInitializationsInEncodeMethod() { @@ -36,7 +42,7 @@ public void Encode_DefaultParameterInitializationsInEncodeMethod() // Try another approach with a list to trigger the generateArrayPrefix default var result2 = Qs.Encode( - new Dictionary { { "a", new[] { "b", "c" } } }, + new Dictionary { { "a", Value } }, new EncodeOptions { // Force the code to use the default initializations @@ -48,7 +54,7 @@ public void Encode_DefaultParameterInitializationsInEncodeMethod() // Try with comma format to trigger the commaRoundTrip default var result3 = Qs.Encode( - new Dictionary { { "a", new[] { "b", "c" } } }, + new Dictionary { { "a", ValueArray } }, new EncodeOptions { ListFormat = ListFormat.Comma, CommaRoundTrip = null } ); result3.Should().Be("a=b%2Cc"); @@ -159,7 +165,7 @@ public void Encode_WithDefaultParameterValues() var customOptions = new EncodeOptions { ListFormat = ListFormat.Comma, Encode = false }; // This should use the default commaRoundTrip value (false) - Qs.Encode(new Dictionary { { "a", new[] { "b" } } }, customOptions) + Qs.Encode(new Dictionary { { "a", ValueArray0 } }, customOptions) .Should() .Be("a=b"); @@ -173,7 +179,7 @@ public void Encode_WithDefaultParameterValues() // This should append [] to single-item lists Qs.Encode( - new Dictionary { { "a", new[] { "b" } } }, + new Dictionary { { "a", Value1 } }, customOptionsWithCommaRoundTrip ) .Should() @@ -210,7 +216,7 @@ public void Encode_PrimitiveBoolWithoutEncoder_UsesLiteralValues() [Fact] public void Encode_NonListEnumerableMaterializesIndices() { - var queue = new Queue(new[] { "a", "b" }); + var queue = new Queue(["a", "b"]); var result = Qs.Encode(new Dictionary { { "queue", queue } }, new EncodeOptions { Encode = false }); result.Should().Be("queue[0]=a&queue[1]=b"); } @@ -238,7 +244,7 @@ public void Encode_AcceptsNonGenericDictionary() { var map = new Hashtable { { "a", "b" }, { 1, "one" } }; var result = Qs.Encode(map, new EncodeOptions { Encode = false }); - result.Split('&').Should().BeEquivalentTo(new[] { "a=b", "1=one" }); + result.Split('&').Should().BeEquivalentTo("a=b", "1=one"); } [Fact] @@ -258,11 +264,7 @@ public void Encode_FilterExceptionsAreIgnored() var options = new EncodeOptions { Encode = false, - Filter = new FunctionFilter((key, value) => - { - if (key.Length == 0) throw new InvalidOperationException(); - return value; - }) + Filter = new FunctionFilter((key, value) => key.Length == 0 ? throw new InvalidOperationException() : value) }; Qs.Encode(data, options).Should().Be("a=1"); @@ -303,13 +305,7 @@ public void Encoder_TreatsOutOfRangeIterableIndicesAsUndefined() [Fact] public void Encode_EncodesLongs() { - var three = 3L; - - string EncodeWithN(object? value, Encoding? encoding, Format? format) - { - var result = Utils.Encode(value, format: format); - return value is long ? $"{result}n" : result; - } + const long three = 3L; Qs.Encode(three).Should().Be(""); Qs.Encode(new List { three }).Should().Be("0=3"); @@ -352,6 +348,13 @@ string EncodeWithN(object? value, Encoding? encoding, Format? format) ) .Should() .Be("a[]=3n"); + return; + + string EncodeWithN(object? value, Encoding? encoding, Format? format) + { + var result = Utils.Encode(value, format: format); + return value is long ? $"{result}n" : result; + } } [Fact] @@ -1856,7 +1859,7 @@ public void Encode_EncodesBooleanValues() [Fact] public void Encode_EncodesBufferValues() { - Qs.Encode(new Dictionary { { "a", Encoding.UTF8.GetBytes("test") } }) + Qs.Encode(new Dictionary { { "a", "test"u8.ToArray() } }) .Should() .Be("a=test"); @@ -1865,7 +1868,7 @@ public void Encode_EncodesBufferValues() { { "a", - new Dictionary { { "b", Encoding.UTF8.GetBytes("test") } } + new Dictionary { { "b", "test"u8.ToArray() } } } } ) @@ -1975,7 +1978,7 @@ public void Encode_SelectsPropertiesWhenFilterIsIterableFilter() { Qs.Encode( new Dictionary { { "a", "b" } }, - new EncodeOptions { Filter = new IterableFilter(new[] { "a" }) } + new EncodeOptions { Filter = new IterableFilter(Iterable) } ) .Should() .Be("a=b"); @@ -2137,11 +2140,6 @@ public void Encode_CanDisableUriEncoding() [Fact] public void Encode_CanSortTheKeys() { - int Sort(object? a, object? b) - { - return string.Compare(a?.ToString(), b?.ToString(), StringComparison.Ordinal); - } - Qs.Encode( new Dictionary { @@ -2168,16 +2166,17 @@ int Sort(object? a, object? b) ) .Should() .Be("a=c&b=f&z%5Bi%5D=b&z%5Bj%5D=a"); - } + return; - [Fact] - public void Encode_CanSortTheKeysAtDepth3OrMoreToo() - { int Sort(object? a, object? b) { return string.Compare(a?.ToString(), b?.ToString(), StringComparison.Ordinal); } + } + [Fact] + public void Encode_CanSortTheKeysAtDepth3OrMoreToo() + { Qs.Encode( new Dictionary { @@ -2243,6 +2242,12 @@ int Sort(object? a, object? b) ) .Should() .Be("a=a&z[zj][zjb]=zjb&z[zj][zja]=zja&z[zi][zib]=zib&z[zi][zia]=zia&b=b"); + return; + + int Sort(object? a, object? b) + { + return string.Compare(a?.ToString(), b?.ToString(), StringComparison.Ordinal); + } } [Fact] @@ -2311,7 +2316,7 @@ public void Encode_CanUseCustomEncoderForBufferMap() .Should() .Be("a=b"); - var bufferWithText = Encoding.UTF8.GetBytes("a b"); + var bufferWithText = "a b"u8.ToArray(); Qs.Encode( new Dictionary { { "a", bufferWithText } }, @@ -2420,7 +2425,7 @@ public void Encode_Rfc1738Serialization() .Be("a+b=c+d"); Qs.Encode( - new Dictionary { { "a b", Encoding.UTF8.GetBytes("a b") } }, + new Dictionary { { "a b", "a b"u8.ToArray() } }, new EncodeOptions { Format = Format.Rfc1738 } ) .Should() @@ -2452,7 +2457,7 @@ public void Encode_Rfc3986SpacesSerialization() .Be("a%20b=c%20d"); Qs.Encode( - new Dictionary { { "a b", Encoding.UTF8.GetBytes("a b") } }, + new Dictionary { { "a b", "a b"u8.ToArray() } }, new EncodeOptions { Format = Format.Rfc3986 } ) .Should() @@ -2464,7 +2469,7 @@ public void Encode_BackwardCompatibilityToRfc3986() { Qs.Encode(new Dictionary { { "a", "b c" } }).Should().Be("a=b%20c"); - Qs.Encode(new Dictionary { { "a b", Encoding.UTF8.GetBytes("a b") } }) + Qs.Encode(new Dictionary { { "a b", "a b"u8.ToArray() } }) .Should() .Be("a%20b=a%20b"); } @@ -3243,7 +3248,7 @@ public void Encode_EncodesNumberValue() [Fact] public void Encode_EncodesBufferValue() { - Qs.Encode(new Dictionary { { "a", Encoding.UTF8.GetBytes("test") } }) + Qs.Encode(new Dictionary { { "a", "test"u8.ToArray() } }) .Should() .Be("a=test"); } @@ -3564,7 +3569,7 @@ public void Encode_CommaListWithSingleElementAndRoundTripAddsArrayBrackets() [Fact] public void Encode_CommaListWithSingleElementAndRoundTripDisabledOmitsArrayBrackets() { - var only = "v"; + const string only = "v"; var result = Qs.Encode( new Dictionary @@ -4356,7 +4361,7 @@ public void ByteArray_Is_Treated_As_Primitive_And_Encoded_With_Default_Encoder() { var data = new Dictionary { - ["b"] = Encoding.UTF8.GetBytes("hi") + ["b"] = "hi"u8.ToArray() }; var qs = Qs.Encode(data, new EncodeOptions()); From 3ee05ec1d2934acfaf125604082d491a2b491fd0 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 7 Oct 2025 20:59:46 +0100 Subject: [PATCH 09/12] :white_check_mark: refactor GetValue test to use a constant for invalid enum value --- QsNet.Tests/SentinelExtensionsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/QsNet.Tests/SentinelExtensionsTests.cs b/QsNet.Tests/SentinelExtensionsTests.cs index faf9b73..f6946f5 100644 --- a/QsNet.Tests/SentinelExtensionsTests.cs +++ b/QsNet.Tests/SentinelExtensionsTests.cs @@ -32,7 +32,7 @@ public void ToString_Extension_ReturnsEncoded_ForIsoAndCharset() [Fact] public void GetValue_Throws_ForInvalidEnum() { - var invalid = (Sentinel)999; + const Sentinel invalid = (Sentinel)999; Action act = () => invalid.GetValue(); act.Should().Throw(); } From 7dee80398404665b6c266833fb2e450cb2a40a2e Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 7 Oct 2025 20:59:51 +0100 Subject: [PATCH 10/12] :white_check_mark: refactor tests to use constants for improved clarity and consistency --- QsNet.Tests/UtilsTests.cs | 37 ++++++++++++++++--------------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/QsNet.Tests/UtilsTests.cs b/QsNet.Tests/UtilsTests.cs index ae057a8..9791636 100644 --- a/QsNet.Tests/UtilsTests.cs +++ b/QsNet.Tests/UtilsTests.cs @@ -348,7 +348,7 @@ public void Escape_HandlesStringWithVariousPunctuation() [Fact] public void Escape_HandlesNullCharacter() { - Utils.Escape("\u0000").Should().Be("%00"); + Utils.Escape("\0").Should().Be("%00"); } [Fact] @@ -1196,9 +1196,9 @@ public void Combine_BothLists() [Fact] public void Combine_OneListOneNonList() { - var aN = 1; + const int aN = 1; var a = new List { aN }; - var bN = 2; + const int bN = 2; var b = new List { bN }; var combinedAnB = Utils.Combine(aN, b); @@ -1213,8 +1213,8 @@ public void Combine_OneListOneNonList() [Fact] public void Combine_NeitherIsList() { - var a = 1; - var b = 2; + const int a = 1; + const int b = 2; var combined = Utils.Combine(a, b); combined.Should().BeEquivalentTo(new List { 1, 2 }); @@ -1250,8 +1250,8 @@ public void InterpretNumericEntities_DecodesASingleDecimalEntity() [Fact] public void InterpretNumericEntities_DecodesMultipleEntitiesInASentence() { - var input = "Hello World!"; - var expected = "Hello World!"; + const string input = "Hello World!"; + const string expected = "Hello World!"; Utils.InterpretNumericEntities(input).Should().Be(expected); } @@ -1761,9 +1761,7 @@ public void Reset() } } - private sealed class CustomType - { - } + private sealed class CustomType; #endregion @@ -2078,7 +2076,7 @@ public void Merge_RemovesUndefinedEntriesWhenListsAreDisabled() [Fact] public void Encode_UnpairedSurrogateFallsBackToThreeByteSequence() { - var highSurrogate = "\uD83D"; // lone high surrogate, invalid pair + const string highSurrogate = "\uD83D"; // lone high surrogate, invalid pair var encoded = Utils.Encode(highSurrogate); encoded.Should().Be("%ED%A0%BD"); } @@ -2119,7 +2117,7 @@ public void ConvertNestedDictionary_HandlesCyclicReferencesWithoutReentry() [Fact] public void Decode_InvalidPercentEncodingFallsBackToOriginal() { - var original = "%E0%A4"; + const string original = "%E0%A4"; var strictEncoding = Encoding.GetEncoding( "utf-8", new EncoderExceptionFallback(), @@ -2132,14 +2130,14 @@ public void Decode_InvalidPercentEncodingFallsBackToOriginal() [Fact] public void InterpretNumericEntities_InvalidDigitsLeaveSequenceUntouched() { - var input = "&#xyz;"; + const string input = "&#xyz;"; Utils.InterpretNumericEntities(input).Should().Be(input); } [Fact] public void InterpretNumericEntities_OverflowDigitsAreLeftAlone() { - var input = "�"; + const string input = "�"; Utils.InterpretNumericEntities(input).Should().Be(input); } @@ -2216,7 +2214,7 @@ public void ToStringKeyDeepNonRecursive_ReusesListsReferencedMultipleTimes() [Fact] public void ToStringKeyDeepNonRecursive_SupportsSelfReferentialLists() { - IList inner = new ArrayList(); + var inner = new ArrayList(); inner.Add(inner); IDictionary root = new Hashtable { ["loop"] = inner }; @@ -2233,19 +2231,16 @@ public void ConvertNestedDictionary_ReturnsExistingStringKeyedInstanceWhenVisite "ConvertNestedDictionary", BindingFlags.NonPublic | BindingFlags.Static, null, - new[] { typeof(IDictionary), typeof(ISet) }, + [typeof(IDictionary), typeof(ISet)], null ); method.Should().NotBeNull(); - var convert = method!; - var dictionary = new Dictionary { ["x"] = 1 }; IDictionary raw = dictionary; - var visited = new HashSet(QsNet.Internal.ReferenceEqualityComparer.Instance); - visited.Add(raw); + var visited = new HashSet(Internal.ReferenceEqualityComparer.Instance) { raw }; - var result = (Dictionary)convert.Invoke(null, new object[] { raw, visited })!; + var result = (Dictionary)method.Invoke(null, [raw, visited])!; result.Should().BeSameAs(dictionary); } From 4e14813d46b2207b947450c47db93c04074ff985 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 7 Oct 2025 21:13:55 +0100 Subject: [PATCH 11/12] :white_check_mark: add test for Encode method to verify Hashtable conversion in filter --- QsNet.Tests/EncodeTests.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/QsNet.Tests/EncodeTests.cs b/QsNet.Tests/EncodeTests.cs index 3b7cb08..5420fde 100644 --- a/QsNet.Tests/EncodeTests.cs +++ b/QsNet.Tests/EncodeTests.cs @@ -284,6 +284,19 @@ public void Encode_SkipNullsSkipsMissingFilteredKeys() Qs.Encode(data, options).Should().Be("present=value"); } + [Fact] + public void Encode_FilterReturningHashtableIsConverted() + { + var data = new Dictionary { ["ignored"] = "value" }; + var options = new EncodeOptions + { + Encode = false, + Filter = new FunctionFilter((key, value) => key.Length == 0 ? new Hashtable { ["x"] = "y" } : value) + }; + + Qs.Encode(data, options).Should().Be("x=y"); + } + [Fact] public void Encoder_TreatsOutOfRangeIterableIndicesAsUndefined() { From 01ffc1ec9563a831494ac2d5aa064a87f1e2afb2 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 7 Oct 2025 21:14:04 +0100 Subject: [PATCH 12/12] :white_check_mark: add tests for Decode method and Compact function to handle edge cases --- QsNet.Tests/UtilsTests.cs | 80 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/QsNet.Tests/UtilsTests.cs b/QsNet.Tests/UtilsTests.cs index 9791636..2a5e94d 100644 --- a/QsNet.Tests/UtilsTests.cs +++ b/QsNet.Tests/UtilsTests.cs @@ -2141,6 +2141,37 @@ public void InterpretNumericEntities_OverflowDigitsAreLeftAlone() Utils.InterpretNumericEntities(input).Should().Be(input); } + private sealed class ThrowingEncoding : Encoding + { + private sealed class ThrowingDecoder : System.Text.Decoder + { + public override int GetCharCount(byte[] bytes, int index, int count) => + throw new InvalidOperationException("decoder failure"); + + public override int GetChars(byte[] bytes, int byteIndex, int byteCount, char[] chars, int charIndex) => + throw new InvalidOperationException("decoder failure"); + } + + public override string EncodingName => "ThrowingEncoding"; + public override int GetByteCount(char[] chars, int index, int count) => throw new NotSupportedException(); + public override int GetBytes(char[] chars, int charIndex, int charCount, byte[] bytes, int byteIndex) => + throw new NotSupportedException(); + public override int GetCharCount(byte[] bytes, int index, int count) => throw new InvalidOperationException(); + public override int GetChars(byte[] bytes, int byteIndex, int byteCount, char[] chars, int charIndex) => + throw new InvalidOperationException(); + public override int GetMaxByteCount(int charCount) => charCount * 2; + public override int GetMaxCharCount(int byteCount) => byteCount; + public override System.Text.Decoder GetDecoder() => new ThrowingDecoder(); + public override byte[] GetPreamble() => []; + } + + [Fact] + public void Decode_ReturnsOriginalWhenUrlDecodeThrows() + { + const string encoded = "%41"; + Utils.Decode(encoded, new ThrowingEncoding()).Should().Be(encoded); + } + [Fact] public void ToStringKeyDeepNonRecursive_ThrowsForNonDictionaryRoot() { @@ -2187,6 +2218,55 @@ public void Compact_ConvertsNestedNonGenericDictionaryWithinStringDictionary() converted["inner"].Should().BeOfType>(); } + [Fact] + public void Compact_TrimsUndefinedAcrossMixedStructures() + { + var nestedObjectDict = new Dictionary(); + var nestedStringDict = new Dictionary { ["keep"] = "value" }; + var nestedList = new List { "item" }; + var oddMap = new Hashtable { ["k"] = "v" }; + + var stringDict = new Dictionary + { + ["undef"] = Undefined.Create(), + ["objectDict"] = nestedObjectDict, + ["stringDict"] = nestedStringDict, + ["list"] = nestedList, + ["odd"] = oddMap + }; + + var list = new List + { + Undefined.Create(), + nestedObjectDict, + nestedStringDict, + nestedList, + new Hashtable { ["z"] = "w" } + }; + + var root = new Dictionary + { + ["map"] = stringDict, + ["list"] = list + }; + + var compacted = Utils.Compact(root); + + var compactedDict = compacted["map"].Should().BeOfType>().Which; + compactedDict.Should().NotContainKey("undef"); + compactedDict["objectDict"].Should().BeOfType>().Which.Should().BeEmpty(); + compactedDict["stringDict"].Should().BeSameAs(nestedStringDict); + compactedDict["list"].Should().BeSameAs(nestedList); + compactedDict["odd"].Should().BeOfType>(); + + var compactedList = compacted["list"].Should().BeOfType>().Which; + compactedList.Should().HaveCount(4); + compactedList[0].Should().BeSameAs(nestedObjectDict); + compactedList[1].Should().BeSameAs(nestedStringDict); + compactedList[2].Should().BeSameAs(nestedList); + compactedList[3].Should().BeOfType>(); + } + [Fact] public void ToStringKeyDeepNonRecursive_ReusesVisitedNodesInLists() {