Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
4228c1f
:art: ensure culture-invariant number formatting and parsing in Utils
techouse Aug 26, 2025
f7d0f2e
:art: use StringBuilder.Append for sentinel encoding and ensure cultu…
techouse Aug 26, 2025
3ff7455
:white_check_mark: update EncodeTests to expect InvalidOperationExcep…
techouse Aug 26, 2025
0530198
:wheelchair: throw InvalidOperationException for cyclic object values…
techouse Aug 26, 2025
2fefa90
:white_check_mark: update DecodeTests to expect InvalidOperationExcep…
techouse Aug 26, 2025
151a145
:wheelchair: replace IndexOutOfRangeException with InvalidOperationEx…
techouse Aug 26, 2025
8fb6865
:white_check_mark: update DecodeOptionsTests to expect InvalidOperati…
techouse Aug 26, 2025
05c870f
:wheelchair: throw InvalidOperationException for invalid DecodeDotInK…
techouse Aug 26, 2025
49efd2a
:package: update project metadata; expand package tags, add release n…
techouse Aug 26, 2025
62222da
:art: update NETSTANDARD2_0 conditional checks to use consistent prep…
techouse Aug 26, 2025
c60336a
:bulb: update exception documentation to reflect InvalidOperationExce…
techouse Aug 26, 2025
62983da
:bulb: update exception documentation to reflect InvalidOperationExce…
techouse Aug 26, 2025
7b28dff
:safety_vest: fix dot encoding in keys to use ordinal string comparis…
techouse Aug 26, 2025
d29200f
:green_heart: enforce warnings as errors during CI builds for stricte…
techouse Aug 26, 2025
d2705bf
:sparkles: improve entity decoding to support hexadecimal numeric cha…
techouse Aug 26, 2025
1656b01
:white_check_mark: add comprehensive tests for hex numeric entity dec…
techouse Aug 26, 2025
65b0573
:safety_vest: fix encoding to append ampersand only when joined strin…
techouse Aug 26, 2025
89351ad
:bulb: clarify decoder delegate remarks to specify return type requir…
techouse Aug 26, 2025
58b4300
:bulb: expand decoder comments to clarify synthetic bracket trimming …
techouse Aug 26, 2025
5ab597c
:recycle: update ParseObject to accept mutable List for chain parameter
techouse Aug 26, 2025
7b8b015
:safety_vest: enforce list limit by accounting for current list lengt…
techouse Aug 26, 2025
30190fa
:white_check_mark: add tests for comma-separated value decoding with …
techouse Aug 26, 2025
5cc572b
:white_check_mark: add tests for uppercase 'X' in hex entities, max v…
techouse Aug 26, 2025
f4300b0
:art: fix whitespace in InterpretNumericEntities hex entity tests
techouse Aug 26, 2025
d2f5494
:zap: optimize numeric entity parsing to avoid per-digit string alloc…
techouse Aug 26, 2025
d2c2bea
:safety_vest: handle invalid surrogate pairs by percent-encoding sing…
techouse Aug 26, 2025
1d0d414
:bug: ensure culture-invariant numeric parsing for list indexing and …
techouse Aug 26, 2025
3ac1490
:zap: make Latin1Encoding static readonly to avoid repeated allocation
techouse Aug 26, 2025
a294b8a
:safety_vest: prevent splitting surrogate pairs across segment bounda…
techouse Aug 26, 2025
0fbfa2c
:white_check_mark: add tests to verify astral character encoding at s…
techouse Aug 26, 2025
531cccd
:bug: fix parent segment parsing for list length calculation in decoder
techouse Aug 26, 2025
ac45d63
:white_check_mark: add tests to clarify comma-split truncation and br…
techouse Aug 26, 2025
0facbea
:art: clean up formatting, indentation, and redundant braces across c…
techouse Aug 26, 2025
acd1faa
:bulb: add commented template for enabling ImplicitUsings in future t…
techouse Aug 26, 2025
ade83a2
:white_check_mark: update decode tests for comma-split truncation, br…
techouse Aug 26, 2025
5c6804a
:white_check_mark: update decode tests to assert exception messages f…
techouse Aug 26, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion QsNet.Tests/DecodeOptionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ public void DecodeKey_ShouldThrow_When_DecodeDotInKeysTrue_And_AllowDotsFalse()
};

Action act = () => options.DecodeKey("a%2Eb", Encoding.UTF8);
act.Should().Throw<ArgumentException>()
act.Should().Throw<InvalidOperationException>()
.Where(e => e.Message.Contains("decodeDotInKeys", StringComparison.OrdinalIgnoreCase)
&& e.Message.Contains("allowDots", StringComparison.OrdinalIgnoreCase));
}
Expand Down
116 changes: 100 additions & 16 deletions QsNet.Tests/DecodeTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Web;
Expand Down Expand Up @@ -272,7 +273,7 @@ public void Should_Throw_When_Comma_List_Limit_Exceeded()

action
.Should()
.Throw<IndexOutOfRangeException>()
.Throw<InvalidOperationException>()
.WithMessage("List limit exceeded. Only 3 elements allowed in a list.");
}

Expand Down Expand Up @@ -2277,7 +2278,7 @@ public void Decode_StrictDepth_ThrowsExceptionForMultipleNestedObjectsWithStrict
var options = new DecodeOptions { Depth = 1, StrictDepth = true };

Action act = () => Qs.Decode("a[b][c][d][e][f][g][h][i]=j", options);
act.Should().Throw<IndexOutOfRangeException>();
act.Should().Throw<InvalidOperationException>();
}

[Fact]
Expand All @@ -2286,7 +2287,7 @@ public void Decode_StrictDepth_ThrowsExceptionForMultipleNestedListsWithStrictDe
var options = new DecodeOptions { Depth = 3, StrictDepth = true };

Action act = () => Qs.Decode("a[0][1][2][3][4]=b", options);
act.Should().Throw<IndexOutOfRangeException>();
act.Should().Throw<InvalidOperationException>();
}

[Fact]
Expand All @@ -2295,7 +2296,7 @@ public void Decode_StrictDepth_ThrowsExceptionForNestedMapsAndListsWithStrictDep
var options = new DecodeOptions { Depth = 3, StrictDepth = true };

Action act = () => Qs.Decode("a[b][c][0][d][e]=f", options);
act.Should().Throw<IndexOutOfRangeException>();
act.Should().Throw<InvalidOperationException>();
}

[Fact]
Expand All @@ -2304,7 +2305,7 @@ public void Decode_StrictDepth_ThrowsExceptionForDifferentTypesOfValuesWithStric
var options = new DecodeOptions { Depth = 3, StrictDepth = true };

Action act = () => Qs.Decode("a[b][c][d][e]=true&a[b][c][d][f]=42", options);
act.Should().Throw<IndexOutOfRangeException>();
act.Should().Throw<InvalidOperationException>();
}

[Fact]
Expand Down Expand Up @@ -2405,7 +2406,7 @@ public void Decode_ParameterLimit_ThrowsErrorWhenParameterLimitExceeded()
var options = new DecodeOptions { ParameterLimit = 3, ThrowOnLimitExceeded = true };

Action act = () => Qs.Decode("a=1&b=2&c=3&d=4&e=5&f=6", options);
act.Should().Throw<IndexOutOfRangeException>();
act.Should().Throw<InvalidOperationException>();
}

[Fact]
Expand Down Expand Up @@ -2483,7 +2484,7 @@ public void Decode_ListLimit_ThrowsErrorWhenListLimitExceeded()
var options = new DecodeOptions { ListLimit = 3, ThrowOnLimitExceeded = true };

Action act = () => Qs.Decode("a[]=1&a[]=2&a[]=3&a[]=4", options);
act.Should().Throw<IndexOutOfRangeException>();
act.Should().Throw<InvalidOperationException>();
}

[Fact]
Expand Down Expand Up @@ -2530,7 +2531,7 @@ public void Decode_ListLimit_HandlesNegativeListLimitCorrectly()
var options = new DecodeOptions { ListLimit = -1, ThrowOnLimitExceeded = true };

Action act = () => Qs.Decode("a[]=1&a[]=2", options);
act.Should().Throw<IndexOutOfRangeException>();
act.Should().Throw<InvalidOperationException>();
}

[Fact]
Expand All @@ -2539,7 +2540,7 @@ public void Decode_ListLimit_AppliesListLimitToNestedLists()
var options = new DecodeOptions { ListLimit = 3, ThrowOnLimitExceeded = true };

Action act = () => Qs.Decode("a[0][]=1&a[0][]=2&a[0][]=3&a[0][]=4", options);
act.Should().Throw<IndexOutOfRangeException>();
act.Should().Throw<InvalidOperationException>();
}

[Fact]
Expand Down Expand Up @@ -4127,10 +4128,50 @@ public void Decode_NonGeneric_Hashtable_Is_Normalised()
decoded.Should().Equal(new Dictionary<string, object?> { ["x"] = 1, ["2"] = "y" });
}

[Fact]
public void Decode_CommaSplit_NoTruncationWhenSumExceedsLimit_AndThrowOff()
{
var opts = new DecodeOptions
{
Comma = true,
ListLimit = 3,
ThrowOnLimitExceeded = false,
ParseLists = true,
Duplicates = Duplicates.Combine
};

var result = Qs.Decode("a=1,2&a=3,4,5", opts);

var dict = Assert.IsType<Dictionary<string, object?>>(result);
var list = Assert.IsType<List<object?>>(dict["a"]);
// With ThrowOnLimitExceeded = false, no truncation occurs; full concatenation is allowed
list.Select(x => x?.ToString()).Should().Equal("1", "2", "3", "4", "5");
}

[Fact]
public void Decode_BracketSingle_CommaSplit_YieldsNestedList()
{
var opts = new DecodeOptions { Comma = true };

// Control: unbracketed key
var res = Qs.Decode("a=1,2,3", opts);
var dict1 = Assert.IsType<Dictionary<string, object?>>(res);
var list1 = Assert.IsType<List<object?>>(dict1["a"]);
list1.Select(x => x?.ToString()).Should().Equal("1", "2", "3");

// Bracketed single occurrence yields a nested list: [["1","2","3"]]
var res2 = Qs.Decode("a[]=1,2,3", opts);
var dict2 = Assert.IsType<Dictionary<string, object?>>(res2);
var outer = Assert.IsType<List<object?>>(dict2["a"]);
outer.Should().HaveCount(1);
var inner = Assert.IsType<List<object?>>(outer[0]);
inner.Select(x => x?.ToString()).Should().Equal("1", "2", "3");
}

#region Encoded dot behavior in keys (%2E / %2e)

[Fact]
public void EncodedDot_TopLevel_AllowDotsTrue_DecodeDotInKeysTrue_PlainDotSplits_EncodedDotDoesNotSplit()
public void EncodedDot_TopLevel_AllowDotsTrue_DecodeDotInKeysTrue_PlainAndEncodedDotSplit()
{
var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true };

Expand All @@ -4157,7 +4198,7 @@ public void EncodedDot_TopLevel_AllowDotsTrue_DecodeDotInKeysTrue_PlainDotSplits
}

[Fact]
public void EncodedDot_TopLevel_AllowDotsTrue_DecodeDotInKeysFalse_EncodedDotRemainsPercentSequence()
public void EncodedDot_TopLevel_AllowDotsTrue_DecodeDotInKeysFalse_EncodedDotAlsoSplits()
{
var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = false };

Expand All @@ -4181,7 +4222,7 @@ public void EncodedDot_AllowDotsFalse_DecodeDotInKeysTrue_IsInvalid()
{
var opt = new DecodeOptions { AllowDots = false, DecodeDotInKeys = true };
Action act = () => Qs.Decode("a%2Eb=c", opt);
act.Should().Throw<ArgumentException>();
act.Should().Throw<InvalidOperationException>();
}

[Fact]
Expand All @@ -4205,7 +4246,7 @@ public void EncodedDot_BracketSegment_MapsToDot_WhenDecodeDotInKeysTrue()
}

[Fact]
public void EncodedDot_BracketSegment_RemainsPercentSequence_WhenDecodeDotInKeysFalse()
public void EncodedDot_BracketSegment_DecodesToDot_WhenDecodeDotInKeysFalse()
{
var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = false };

Expand Down Expand Up @@ -4349,8 +4390,9 @@ public void MixedCase_EncodedBrackets_EncodedDot_AllowDotsFalse_DecodeDotInKeysT
{
var opt = new DecodeOptions { AllowDots = false, DecodeDotInKeys = true };
Action act = () => Qs.Decode("a%5Bb%5D%5Bc%5D%2Ed=x", opt);
act.Should().Throw<ArgumentException>()
.WithMessage("*decodeDotInKeys*allowDots*");
act.Should().Throw<InvalidOperationException>()
.WithMessage("*DecodeDotInKeys*AllowDots*")
.WithMessage("*DecodeDotInKeys=true*AllowDots=true*");
}

[Fact]
Expand Down Expand Up @@ -4646,7 +4688,7 @@ public void StrictDepthOverflow_RaisesForWellFormed()
{
var act = () =>
InternalDecoder.SplitKeyIntoSegments("a[b][c][d]", false, 1, true);
act.Should().Throw<IndexOutOfRangeException>();
act.Should().Throw<InvalidOperationException>();
}

[Fact]
Expand Down Expand Up @@ -4681,4 +4723,46 @@ public void LeadingDot_EncodedBracket_AllowDotsTrue_DecodeDotInKeysTrue()
}

#endregion

#region Decode comma limit

[Fact]
public void Decode_CommaSplit_AllowedWhenSumEqualsLimit()
{
var opts = new DecodeOptions
{
Comma = true,
ListLimit = 5,
ThrowOnLimitExceeded = true,
ParseLists = true,
Duplicates = Duplicates.Combine
};

// Existing N=2 from first part, incoming M=3; N+M = 5 == limit → allowed
var result = Assert.IsType<Dictionary<string, object?>>(Qs.Decode("a=1,2&a=3,4,5", opts));
result.Should().ContainKey("a");

var list = Assert.IsType<List<object?>>(result["a"]);
list.Should().HaveCount(5);
list.Select(x => x?.ToString()).Should().Equal("1", "2", "3", "4", "5");
}

[Fact]
public void Decode_CommaSplit_ThrowsWhenSumExceedsLimitAndThrowOn()
{
var opts = new DecodeOptions
{
Comma = true,
ListLimit = 5,
ThrowOnLimitExceeded = true,
ParseLists = true,
Duplicates = Duplicates.Combine
};

// Existing N=2, incoming M=4; N+M = 6 > limit and ThrowOnLimitExceeded = true → throws
Action act = () => Qs.Decode("a=1,2&a=3,4,5,6", opts);
act.Should().Throw<InvalidOperationException>();
}

#endregion
}
8 changes: 4 additions & 4 deletions QsNet.Tests/EncodeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1790,13 +1790,13 @@ public void Encode_DoesNotCrashWhenParsingCircularReferences()

Action act1 = () =>
Qs.Encode(new Dictionary<string, object?> { { "foo[bar]", "baz" }, { "foo[baz]", a } });
act1.Should().Throw<IndexOutOfRangeException>();
act1.Should().Throw<InvalidOperationException>();

var circular = new Dictionary<string, object?> { { "a", "value" } };
circular["a"] = circular;

Action act2 = () => Qs.Encode(circular);
act2.Should().Throw<IndexOutOfRangeException>();
act2.Should().Throw<InvalidOperationException>();

var arr = new List<object?> { "a" };
Action act3 = () =>
Expand Down Expand Up @@ -3405,7 +3405,7 @@ public void Encode_ThrowsOnSelfReferentialMap()
a["self"] = a;

var act = () => Qs.Encode(new Dictionary<string, object?> { { "a", a } });
act.Should().Throw<IndexOutOfRangeException>();
act.Should().Throw<InvalidOperationException>();
}

[Fact]
Expand All @@ -3415,7 +3415,7 @@ public void Encode_ThrowsOnSelfReferentialList()
l.Add(l);

var act = () => Qs.Encode(new Dictionary<string, object?> { { "l", l } });
act.Should().Throw<IndexOutOfRangeException>();
act.Should().Throw<InvalidOperationException>();
}

[Fact]
Expand Down
1 change: 1 addition & 0 deletions QsNet.Tests/Fixtures/DummyEnum.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ internal enum DummyEnum
// ReSharper disable InconsistentNaming
LOREM,
IPSUM,

DOLOR
// ReSharper restore InconsistentNaming
}
80 changes: 78 additions & 2 deletions QsNet.Tests/UtilsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1285,8 +1285,8 @@ public void InterpretNumericEntities_MalformedOrUnsupportedPatternsRemainUnchang
Utils.InterpretNumericEntities("&#;").Should().Be("&#;");
// Missing terminating semicolon
Utils.InterpretNumericEntities("&#12").Should().Be("&#12");
// Hex form not supported by this decoder
Utils.InterpretNumericEntities("&#x41;").Should().Be("&#x41;");
// Hex form is supported by this decoder
Utils.InterpretNumericEntities("&#x41;").Should().Be("A");
// Space inside
Utils.InterpretNumericEntities("&# 12;").Should().Be("&# 12;");
// Negative / non-digit after '#'
Expand All @@ -1302,6 +1302,62 @@ public void InterpretNumericEntities_OutOfRangeCodePointsRemainUnchanged()
Utils.InterpretNumericEntities("&#1114112;").Should().Be("&#1114112;");
}

[Fact]
public void InterpretNumericEntities_DecodesSingleHexEntity()
{
Utils.InterpretNumericEntities("&#x41;").Should().Be("A"); // uppercase hex digits
Utils.InterpretNumericEntities("&#x6d;").Should().Be("m"); // lowercase hex digits
}

[Fact]
public void InterpretNumericEntities_DecodesSingleHexEntity_UppercaseX()
{
Utils.InterpretNumericEntities("&#X41;").Should().Be("A");
}

[Fact]
public void InterpretNumericEntities_AcceptsMaxValidHexAndRejectsBeyond()
{
// U+10FFFF is valid
Utils.InterpretNumericEntities("&#x10FFFF;").Should().Be(char.ConvertFromUtf32(0x10FFFF));
// One above max should remain unchanged
Utils.InterpretNumericEntities("&#x110000;").Should().Be("&#x110000;");
}

[Fact]
public void InterpretNumericEntities_EmptyHexDigitsRemainUnchanged()
{
Utils.InterpretNumericEntities("&#x;").Should().Be("&#x;");
Utils.InterpretNumericEntities("&#X;").Should().Be("&#X;");
}

[Fact]
public void InterpretNumericEntities_DecodesMultipleHexEntities()
{
Utils.InterpretNumericEntities("&#x48;&#x0069;!").Should().Be("Hi!");
}

[Fact]
public void InterpretNumericEntities_DecodesHexSurrogatePair()
{
// U+1F4A9 (💩) as surrogate halves: 0xD83D, 0xDCA9
Utils.InterpretNumericEntities("&#xD83D;&#xDCA9;").Should().Be("💩");
}

[Fact]
public void InterpretNumericEntities_MixedDecimalAndHexEntities()
{
Utils.InterpretNumericEntities("A = &#x41; and &#66;").Should().Be("A = A and B");
}

[Fact]
public void InterpretNumericEntities_InvalidHexEntitiesRemainUnchanged()
{
Utils.InterpretNumericEntities("&#xZZ;").Should().Be("&#xZZ;"); // non-hex digits
Utils.InterpretNumericEntities("&#x1G;").Should().Be("&#x1G;"); // invalid hex digit
Utils.InterpretNumericEntities("&#x41").Should().Be("&#x41"); // missing semicolon
}

[Fact]
public void Apply_OnScalarAndList()
{
Expand Down Expand Up @@ -1567,4 +1623,24 @@ public void ToStringKeyDeepNonRecursive_Converts_Nested_Lists_And_Dicts()

outTopList[2].Should().Be(4);
}

[Fact]
public void EnsureAstralCharactersAtSegmentLimitMinus1OrSegmentLimitEncodeAs4ByteSequences()
{
const int SegmentLimit = 1024;
// Ensure astral characters at SegmentLimit-1/SegmentLimit encode as 4-byte sequences
var s = new string('a', SegmentLimit - 1) + "\U0001F600" + "b";
var encoded = Utils.Encode(s, Encoding.UTF8, Format.Rfc3986);
Assert.Contains("%F0%9F%98%80", encoded);
}

[Fact]
public void EnsureAstralCharactersAtSegmentLimitEncodeAs4ByteSequences()
{
const int SegmentLimit = 1024;
// Astral character starts exactly at the chunk boundary (index == SegmentLimit)
var s = new string('a', SegmentLimit) + "\U0001F600" + "b";
var encoded = Utils.Encode(s, Encoding.UTF8, Format.Rfc3986);
Assert.Contains("%F0%9F%98%80", encoded);
}
}
Loading
Loading