Skip to content

Commit 3a9a0eb

Browse files
authored
✅ add more tests (#34)
1 parent 699b1ec commit 3a9a0eb

File tree

8 files changed

+572
-13
lines changed

8 files changed

+572
-13
lines changed

qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/EncodeSpec.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import java.time.Instant
2020
import java.time.LocalDateTime
2121
import java.time.ZoneId
2222
import java.time.ZoneOffset
23-
import java.util.Locale
23+
import java.util.*
2424

2525
class EncodeSpec :
2626
DescribeSpec({
@@ -112,7 +112,7 @@ class EncodeSpec :
112112
EncodeOptions(
113113
encode = false,
114114
filter =
115-
FunctionFilter { prefix: String, map: Any? ->
115+
FunctionFilter { _: String, map: Any? ->
116116
// This should trigger the code path that accesses
117117
// properties of non-Map, non-Iterable objects
118118
val result = mutableMapOf<String, Any?>()
@@ -1184,7 +1184,7 @@ class EncodeSpec :
11841184

11851185
value is LocalDateTime -> {
11861186
prefix shouldBe "e[f]"
1187-
value.atZone(java.time.ZoneOffset.UTC).toInstant().toEpochMilli()
1187+
value.atZone(ZoneOffset.UTC).toInstant().toEpochMilli()
11881188
}
11891189

11901190
else -> value
@@ -2014,7 +2014,7 @@ class EncodeSpec :
20142014

20152015
it("FunctionFilter branch inside recursion (prefix non-empty)") {
20162016
var count = 0
2017-
val filter = FunctionFilter { prefix, value ->
2017+
val filter = FunctionFilter { _, value ->
20182018
count += 1
20192019
// When prefix not empty return value unchanged; at root return original map
20202020
value
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package io.github.techouse.qskotlin.unit
2+
3+
import io.github.techouse.qskotlin.enums.Sentinel
4+
import io.kotest.core.spec.style.FunSpec
5+
import io.kotest.matchers.shouldBe
6+
7+
class SentinelSpec :
8+
FunSpec({
9+
test("sentinel encoded values are exposed") {
10+
Sentinel.ISO.asQueryParam() shouldBe Sentinel.ISO.toString()
11+
Sentinel.CHARSET.toEntry().key shouldBe Sentinel.PARAM_NAME
12+
}
13+
})

qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/UtilsSpec.kt

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package io.github.techouse.qskotlin.unit
33
import io.github.techouse.qskotlin.enums.Format
44
import io.github.techouse.qskotlin.fixtures.DummyEnum
55
import io.github.techouse.qskotlin.internal.Utils
6+
import io.github.techouse.qskotlin.models.DecodeOptions
67
import io.github.techouse.qskotlin.models.Undefined
78
import io.kotest.core.spec.style.FunSpec
89
import io.kotest.matchers.maps.shouldContainKey
@@ -129,6 +130,10 @@ class UtilsSpec :
129130
Utils.encode(Undefined()) shouldBe ""
130131
}
131132

133+
test("falls back to defaults when charset or format null") {
134+
Utils.encode("plain", null, null) shouldBe "plain"
135+
}
136+
132137
test("handles special characters") {
133138
Utils.encode("~._-") shouldBe "~._-"
134139
Utils.encode("!@#\$%^&*()") shouldBe "%21%40%23%24%25%5E%26%2A%28%29"
@@ -194,6 +199,10 @@ class UtilsSpec :
194199
Utils.decode("foo%28bar%29", StandardCharsets.ISO_8859_1) shouldBe "foo(bar)"
195200
}
196201

202+
test("invalid percent sequence falls back to literal") {
203+
Utils.decode("%E0") shouldBe "\uFFFD"
204+
}
205+
197206
test("decodes URL-encoded strings") {
198207
Utils.decode("a+b") shouldBe "a b"
199208
Utils.decode("name%2Eobj") shouldBe "name.obj"
@@ -202,6 +211,25 @@ class UtilsSpec :
202211
}
203212
}
204213

214+
context("Utils.compact") {
215+
test("removes undefined entries and compacts nested lists") {
216+
val inner = mutableListOf<Any?>(Undefined(), "value")
217+
val root = mutableMapOf<String, Any?>("a" to Undefined(), "b" to inner)
218+
219+
val compacted = Utils.compact(root, allowSparseLists = false)
220+
compacted shouldBe mutableMapOf<String, Any?>("b" to mutableListOf<Any?>("value"))
221+
}
222+
223+
test("respects allowSparseLists and avoids cycles") {
224+
val list = mutableListOf<Any?>(Undefined(), "v")
225+
val root = mutableMapOf<String, Any?>("self" to list)
226+
list += root // introduce cycle
227+
228+
val compacted = Utils.compact(root, allowSparseLists = true)
229+
compacted["self"].shouldBeInstanceOf<MutableList<*>>()
230+
}
231+
}
232+
205233
@Suppress("DEPRECATION")
206234
context("Utils.escape") {
207235
test("handles basic alphanumerics (remain unchanged)") {
@@ -368,6 +396,42 @@ class UtilsSpec :
368396
}
369397

370398
context("Utils.merge") {
399+
test("merges custom iterable target with iterable source producing list with wrapper") {
400+
val target = BoxIterable(listOf("foo"))
401+
val result = Utils.merge(target, listOf("bar"))
402+
403+
result.shouldBeInstanceOf<List<*>>()
404+
result.size shouldBe 2
405+
result[0] shouldBe target
406+
result[1] shouldBe "bar"
407+
}
408+
409+
test("merges custom iterable target with scalar source into list") {
410+
val target = BoxIterable(listOf("foo"))
411+
val result = Utils.merge(target, "bar")
412+
413+
result.shouldBeInstanceOf<List<*>>()
414+
result.size shouldBe 2
415+
result[0] shouldBe target
416+
result[1] shouldBe "bar"
417+
}
418+
419+
test("merges iterables of maps by index and merges entries") {
420+
val target = listOf(mapOf("a" to 1))
421+
val source = listOf(mapOf("b" to 2))
422+
423+
val result = Utils.merge(target, source)
424+
result shouldBe listOf(mapOf("a" to 1, "b" to 2))
425+
}
426+
427+
test("merges custom iterable of maps preserving merge semantics") {
428+
val target = BoxIterable(listOf(mapOf("a" to 1)))
429+
val source = listOf(mapOf("b" to 2))
430+
431+
val result = Utils.merge(target, source)
432+
result shouldBe listOf(mapOf("a" to 1, "b" to 2))
433+
}
434+
371435
test("merges Map with List") {
372436
Utils.merge(mapOf(0 to "a"), listOf(Undefined(), "b")) shouldBe
373437
mapOf(0 to "a", "1" to "b")
@@ -584,6 +648,26 @@ class UtilsSpec :
584648
map.shouldContainKey("foo")
585649
map["foo"].shouldBeInstanceOf<Set<Map<String, String>>>()
586650
}
651+
652+
test("merge trims undefined placeholders when parseLists disabled") {
653+
val result =
654+
Utils.merge(
655+
listOf(Undefined(), "keep"),
656+
emptyList<String>(),
657+
DecodeOptions(parseLists = false),
658+
)
659+
result shouldBe mapOf<String, Any?>("1" to "keep")
660+
}
661+
662+
test("merge scalar with iterable promotes to list without undefined") {
663+
val result = Utils.merge("left", listOf("right", Undefined()))
664+
result shouldBe listOf("left", "right")
665+
}
666+
667+
test("merge iterable target with map source indexes existing elements") {
668+
val result = Utils.merge(listOf("a", Undefined()), mapOf("b" to "c"))
669+
result shouldBe mapOf<String, Any?>("0" to "a", "b" to "c")
670+
}
587671
}
588672

589673
context("Utils.combine") {
@@ -650,6 +734,10 @@ class UtilsSpec :
650734
Utils.interpretNumericEntities("&#55357;&#56489;") shouldBe "💩"
651735
}
652736

737+
test("decodes single entity above BMP as surrogate pair") {
738+
Utils.interpretNumericEntities("&#128169;") shouldBe "💩"
739+
}
740+
653741
test("entities can appear at string boundaries") {
654742
Utils.interpretNumericEntities("&#65;BC") shouldBe "ABC"
655743
Utils.interpretNumericEntities("ABC&#33;") shouldBe "ABC!"
@@ -694,12 +782,18 @@ class UtilsSpec :
694782
test("treats URI as primitive, honors skipNulls for empty string") {
695783
Utils.isNonNullishPrimitive(URI("https://example.com")) shouldBe true
696784
Utils.isNonNullishPrimitive("", skipNulls = true) shouldBe false
785+
Utils.isNonNullishPrimitive(URI(""), skipNulls = true) shouldBe false
697786
}
698787
}
699788

700789
context("Utils.isEmpty") {
701790
test("empty collections and maps") {
702791
Utils.isEmpty(emptyMap<String, Any?>()) shouldBe true
792+
Utils.isEmpty(emptyList<String>()) shouldBe true
703793
}
704794
}
705795
})
796+
797+
private class BoxIterable<T>(private val items: List<T>) : Iterable<T> {
798+
override fun iterator(): Iterator<T> = items.iterator()
799+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package io.github.techouse.qskotlin.unit.internal
2+
3+
import io.github.techouse.qskotlin.internal.Decoder
4+
import io.github.techouse.qskotlin.models.DecodeOptions
5+
import io.kotest.assertions.throwables.shouldThrow
6+
import io.kotest.core.spec.style.DescribeSpec
7+
import io.kotest.matchers.shouldBe
8+
import java.nio.charset.StandardCharsets
9+
10+
class DecoderInternalSpec :
11+
DescribeSpec({
12+
describe("Decoder.parseQueryStringValues") {
13+
it("rejects non-positive parameter limits") {
14+
shouldThrow<IllegalArgumentException> {
15+
Decoder.parseQueryStringValues("a=1", DecodeOptions(parameterLimit = 0))
16+
}
17+
}
18+
19+
it("throws when parameter limit exceeded and throwOnLimitExceeded=true") {
20+
val options = DecodeOptions(parameterLimit = 1, throwOnLimitExceeded = true)
21+
shouldThrow<IndexOutOfBoundsException> {
22+
Decoder.parseQueryStringValues("a=1&b=2", options)
23+
}
24+
}
25+
26+
it("splits comma-delimited lists respecting limits") {
27+
val options = DecodeOptions(comma = true)
28+
val result = Decoder.parseQueryStringValues("list=a,b", options)
29+
result["list"] shouldBe listOf("a", "b")
30+
}
31+
32+
it("respects charset sentinel and numeric entities") {
33+
val options =
34+
DecodeOptions(
35+
interpretNumericEntities = true,
36+
charset = StandardCharsets.ISO_8859_1,
37+
charsetSentinel = true,
38+
ignoreQueryPrefix = true,
39+
)
40+
41+
val result =
42+
Decoder.parseQueryStringValues("?utf8=%26%2310003%3B&name=%26%2365%3B", options)
43+
result.containsKey("name") shouldBe true
44+
result["name"] shouldBe "A"
45+
}
46+
}
47+
})
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package io.github.techouse.qskotlin.unit.internal
2+
3+
import io.github.techouse.qskotlin.enums.Format
4+
import io.github.techouse.qskotlin.enums.ListFormat
5+
import io.github.techouse.qskotlin.internal.Encoder
6+
import io.github.techouse.qskotlin.models.IterableFilter
7+
import io.kotest.core.spec.style.DescribeSpec
8+
import io.kotest.matchers.shouldBe
9+
import io.kotest.matchers.types.shouldBeInstanceOf
10+
import java.nio.charset.StandardCharsets
11+
import java.time.LocalDateTime
12+
13+
class EncoderInternalSpec :
14+
DescribeSpec({
15+
describe("Encoder.encode internals") {
16+
it("applies defaults when optional parameters are null") {
17+
val calls = mutableListOf<String>()
18+
val data = arrayOf("v1")
19+
val result =
20+
Encoder.encode(
21+
data = data,
22+
undefined = false,
23+
sideChannel = mutableMapOf(),
24+
prefix = null,
25+
generateArrayPrefix = null,
26+
commaRoundTrip = null,
27+
encoder = { value, _, _ ->
28+
val text = value?.toString() ?: "<null>"
29+
calls += text
30+
"enc:$text"
31+
},
32+
format = Format.RFC3986,
33+
formatter = Format.RFC3986.formatter,
34+
charset = StandardCharsets.UTF_8,
35+
addQueryPrefix = true,
36+
)
37+
38+
result.shouldBeInstanceOf<String>()
39+
result shouldBe "enc:?=enc:$data"
40+
calls shouldBe listOf("?", data.toString())
41+
}
42+
43+
it("encodes arrays using indices generator") {
44+
val data = arrayOf("v0", "v1")
45+
val result =
46+
Encoder.encode(
47+
data = data,
48+
undefined = false,
49+
sideChannel = mutableMapOf(),
50+
prefix = "arr",
51+
generateArrayPrefix = ListFormat.INDICES.generator,
52+
encoder = { value, _, _ -> value?.toString() ?: "" },
53+
format = Format.RFC3986,
54+
formatter = Format.RFC3986.formatter,
55+
charset = StandardCharsets.UTF_8,
56+
)
57+
58+
result.shouldBeInstanceOf<String>()
59+
result.startsWith("arr=") shouldBe true
60+
}
61+
62+
it("skips out-of-range array indices exposed by IterableFilter") {
63+
val result =
64+
Encoder.encode(
65+
data = arrayOf("only"),
66+
undefined = false,
67+
sideChannel = mutableMapOf(),
68+
prefix = "arr",
69+
generateArrayPrefix = ListFormat.INDICES.generator,
70+
filter = IterableFilter(listOf(2)),
71+
encoder = { value, _, _ -> value?.toString() ?: "" },
72+
format = Format.RFC3986,
73+
formatter = Format.RFC3986.formatter,
74+
charset = StandardCharsets.UTF_8,
75+
)
76+
77+
result.shouldBeInstanceOf<String>()
78+
result.startsWith("arr=") shouldBe true
79+
}
80+
81+
it("handles unsupported object types by skipping values") {
82+
val result =
83+
Encoder.encode(
84+
data = Plain("value"),
85+
undefined = false,
86+
sideChannel = mutableMapOf(),
87+
prefix = "plain",
88+
generateArrayPrefix = ListFormat.INDICES.generator,
89+
filter = IterableFilter(listOf("prop")),
90+
encoder = { value, _, _ -> value?.toString() ?: "" },
91+
format = Format.RFC3986,
92+
formatter = Format.RFC3986.formatter,
93+
charset = StandardCharsets.UTF_8,
94+
)
95+
96+
result.shouldBeInstanceOf<String>()
97+
result shouldBe "plain=value"
98+
}
99+
100+
it("serializes LocalDateTime values with custom serializer") {
101+
val stamp = LocalDateTime.parse("2024-05-01T10:15:00")
102+
val result =
103+
Encoder.encode(
104+
data = stamp,
105+
undefined = false,
106+
sideChannel = mutableMapOf(),
107+
prefix = "ts",
108+
serializeDate = { dt -> "X${dt.toLocalDate()}" },
109+
encoder = { value, _, _ -> value?.toString() ?: "" },
110+
format = Format.RFC3986,
111+
formatter = Format.RFC3986.formatter,
112+
charset = StandardCharsets.UTF_8,
113+
)
114+
115+
result.shouldBeInstanceOf<String>()
116+
result shouldBe "ts=X2024-05-01"
117+
}
118+
}
119+
})
120+
121+
private class Plain(private val value: String) {
122+
override fun toString(): String = value
123+
}

0 commit comments

Comments
 (0)