From c60044781477180845a89b323dacb07ac8d7e751 Mon Sep 17 00:00:00 2001 From: Sascha Bieberstein Date: Fri, 10 Jul 2026 13:41:04 +0200 Subject: [PATCH 1/4] Expand JSON tests to pin current amount serialization behaviour pre upgrade Assisted-by: Claude Fable 5 Fixes: SIRI-1149 --- src/test/kotlin/sirius/kernel/commons/JsonTest.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/test/kotlin/sirius/kernel/commons/JsonTest.kt b/src/test/kotlin/sirius/kernel/commons/JsonTest.kt index f3af34ca..3453b848 100644 --- a/src/test/kotlin/sirius/kernel/commons/JsonTest.kt +++ b/src/test/kotlin/sirius/kernel/commons/JsonTest.kt @@ -422,6 +422,15 @@ class JsonTest { assertEquals(inputAmount, parsedAmount) } + @Test + fun `Amount serialization handles NOTHING and unrounded values`() { + // pins the behavior of the explicit Amount serializer in Json.MAPPER which replaces the + // @JsonValue handling that Jackson 3 no longer applies to subclasses of Number + assertEquals("null", Json.MAPPER.writeValueAsString(Amount.NOTHING)) + assertEquals("1.23", Json.MAPPER.writeValueAsString(Amount.of(BigDecimal("1.23456789")))) + assertEquals("1.23456789", Json.MAPPER.writeValueAsString(Amount.ofRounded(BigDecimal("1.23456789")))) + } + @Test fun `Read Amount from POJONode`() { val inputAmount = Amount.ofRounded(BigDecimal("1.23456789")) From 62452a2903aa32bcf269d906d619fe3303b8c8e6 Mon Sep 17 00:00:00 2001 From: Sascha Bieberstein Date: Fri, 10 Jul 2026 14:31:20 +0200 Subject: [PATCH 2/4] Upgrades Jackson from 2.22.0 to 3.2.0 Switches jackson-databind to the new tools.jackson.core coordinates and adjusts all imports accordingly. The jackson-annotations artifact intentionally stays on the com.fasterxml.jackson 2.x line, as Jackson 3 continues to use it. The jackson-datatype-jsr310 dependency is removed because java.time support is built into jackson-databind 3. Json.MAPPER is now constructed via the builder API (mappers are immutable in Jackson 3) based on builderWithJackson2Defaults(), so the wire format and parsing behavior remain unchanged for API consumers despite Jackson 3 flipping several serialization defaults (alphabetical property ordering, dates, enums, stricter parsing). Adopting the new defaults remains a deliberate, separate step. Jackson 3 no longer honors @JsonValue on subclasses of Number, which silently broke Amount serialization to a locale-dependent string. As class-level @JsonSerialize annotations are consulted before the standard Number handling, Amount now declares an explicit serializer replicating the previous behavior. Unlike a mapper-registered module, this also covers mappers constructed outside of Json and node serialization via toString(). Note that node.toString() no longer serializes POJO nodes through the mapper - Json.write() must be used instead. Jackson exceptions are now unchecked; the try-prefixed methods in Json keep their signatures with JacksonException in place of JsonProcessingException. Assisted-by: Claude Fable 5 Fixes: SIRI-1149 --- pom.xml | 6 +- .../java/sirius/kernel/commons/Amount.java | 32 +++++++ src/main/java/sirius/kernel/commons/Json.java | 88 ++++++++++--------- .../kotlin/sirius/kernel/commons/JsonTest.kt | 28 +++--- 4 files changed, 92 insertions(+), 62 deletions(-) diff --git a/pom.xml b/pom.xml index 1377df2f..eeebf271 100644 --- a/pom.xml +++ b/pom.xml @@ -89,13 +89,9 @@ - com.fasterxml.jackson.core + tools.jackson.core jackson-databind - - com.fasterxml.jackson.datatype - jackson-datatype-jsr310 - org.apache.commons diff --git a/src/main/java/sirius/kernel/commons/Amount.java b/src/main/java/sirius/kernel/commons/Amount.java index 98a3f9ee..29a01114 100644 --- a/src/main/java/sirius/kernel/commons/Amount.java +++ b/src/main/java/sirius/kernel/commons/Amount.java @@ -11,6 +11,11 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonValue; import sirius.kernel.nls.NLS; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ValueSerializer; +import tools.jackson.databind.annotation.JsonSerialize; import javax.annotation.CheckReturnValue; import javax.annotation.Nonnull; @@ -46,6 +51,7 @@ /// @see NumberFormat /// @see BigDecimal @Immutable +@JsonSerialize(using = Amount.JacksonSerializer.class) public class Amount extends Number implements Comparable { @Serial @@ -262,6 +268,11 @@ public BigDecimal fetchAmountWithoutTrailingZeros() { /// Unwraps the internally used BigDecimal with rounding like in [#toMachineString()] applied. /// This is used for Jackson Object Mapping. /// + /// Note that Jackson 3 ignores `@JsonValue` on subclasses of [Number] (the standard number serializer takes + /// precedence), therefore [JacksonSerializer] is registered via `@JsonSerialize` on the class, which is consulted + /// before the standard handling. The annotation is kept for documentation purposes and in case Jackson restores its + /// precedence. + /// /// @return the internally used BigDecimal with rounding applied @Nullable @JsonValue @@ -273,6 +284,27 @@ private BigDecimal getRoundedAmount() { } } + /// Serializes an [Amount] as its rounded BigDecimal value (or null for [#NOTHING]), replicating + /// the `@JsonValue` behavior of Jackson 2 which no longer applies to subclasses of [Number] in Jackson 3. + public static class JacksonSerializer extends ValueSerializer { + /** + * @param value Value to serialize; can not be null. + * @param generator Generator used to output resulting JSON content + * @param context Context that can be used to get serializers for + * serializing Objects value contains, if any. + * @throws JacksonException when the serialization fails + */ + @Override + public void serialize(Amount value, JsonGenerator generator, SerializationContext context) { + BigDecimal roundedAmount = value.getRoundedAmount(); + if (roundedAmount == null) { + generator.writeNull(); + } else { + generator.writeNumber(roundedAmount); + } + } + } + @Override public int intValue() { throwExceptionIfEmpty(); diff --git a/src/main/java/sirius/kernel/commons/Json.java b/src/main/java/sirius/kernel/commons/Json.java index df7d4554..69508262 100644 --- a/src/main/java/sirius/kernel/commons/Json.java +++ b/src/main/java/sirius/kernel/commons/Json.java @@ -8,20 +8,21 @@ package sirius.kernel.commons; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.core.JsonPointer; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.StreamReadConstraints; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fasterxml.jackson.databind.node.POJONode; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import sirius.kernel.health.Exceptions; import sirius.kernel.health.Log; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonPointer; +import tools.jackson.core.StreamReadConstraints; +import tools.jackson.core.json.JsonFactory; +import tools.jackson.core.json.JsonReadFeature; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.cfg.DateTimeFeature; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; +import tools.jackson.databind.node.POJONode; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -57,16 +58,17 @@ public class Json { *

* Note that this mapper is configured to allow single quotes and unquoted field names when parsing JSON strings. */ - public static final ObjectMapper MAPPER = - new ObjectMapper().configure(JsonParser.Feature.ALLOW_UNQUOTED_FIELD_NAMES, true) - .configure(JsonParser.Feature.ALLOW_SINGLE_QUOTES, true) - .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) - .registerModule(new JavaTimeModule()); - - static { - MAPPER.getFactory() - .setStreamReadConstraints(StreamReadConstraints.builder().maxStringLength(25_000_000).build()); - } + public static final ObjectMapper MAPPER = JsonMapper.builder(JsonFactory.builderWithJackson2Defaults() + .enable(JsonReadFeature.ALLOW_UNQUOTED_PROPERTY_NAMES, + JsonReadFeature.ALLOW_SINGLE_QUOTES) + .streamReadConstraints(StreamReadConstraints.builder() + .maxStringLength( + 25_000_000) + .build()) + .build()) + .configureForJackson2() + .disable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS) + .build(); private Json() { } @@ -126,7 +128,7 @@ public static ArrayNode createArray(@Nonnull Collection elements) { public static ObjectNode parseObject(String json) { try { return tryParseObject(json); - } catch (JsonProcessingException exception) { + } catch (JacksonException exception) { throw Exceptions.handle(LOG, exception); } } @@ -136,9 +138,9 @@ public static ObjectNode parseObject(String json) { * * @param json the JSON string to parse * @return the parsed JSON string as object node - * @throws JsonProcessingException in case of a malformed JSON string + * @throws JacksonException in case of a malformed JSON string */ - public static ObjectNode tryParseObject(String json) throws JsonProcessingException { + public static ObjectNode tryParseObject(String json) throws JacksonException { return MAPPER.readValue(json, ObjectNode.class); } @@ -151,7 +153,7 @@ public static ObjectNode tryParseObject(String json) throws JsonProcessingExcept public static ArrayNode parseArray(String json) { try { return tryParseArray(json); - } catch (JsonProcessingException exception) { + } catch (JacksonException exception) { throw Exceptions.handle(LOG, exception); } } @@ -161,9 +163,9 @@ public static ArrayNode parseArray(String json) { * * @param json the JSON string to parse * @return the parsed JSON string as array node - * @throws JsonProcessingException in case of a malformed JSON string + * @throws JacksonException in case of a malformed JSON string */ - public static ArrayNode tryParseArray(String json) throws JsonProcessingException { + public static ArrayNode tryParseArray(String json) throws JacksonException { return MAPPER.readValue(json, ArrayNode.class); } @@ -176,7 +178,7 @@ public static ArrayNode tryParseArray(String json) throws JsonProcessingExceptio public static String write(JsonNode objectNode) { try { return tryWrite(objectNode); - } catch (JsonProcessingException exception) { + } catch (JacksonException exception) { throw Exceptions.handle(LOG, exception); } } @@ -186,9 +188,9 @@ public static String write(JsonNode objectNode) { * * @param objectNode the node to convert * @return the JSON string representing the given node - * @throws JsonProcessingException in case the given node cannot be converted + * @throws JacksonException in case the given node cannot be converted */ - public static String tryWrite(JsonNode objectNode) throws JsonProcessingException { + public static String tryWrite(JsonNode objectNode) throws JacksonException { return MAPPER.writeValueAsString(objectNode); } @@ -201,7 +203,7 @@ public static String tryWrite(JsonNode objectNode) throws JsonProcessingExceptio public static String writePretty(JsonNode objectNode) { try { return tryWritePretty(objectNode); - } catch (JsonProcessingException exception) { + } catch (JacksonException exception) { throw Exceptions.handle(LOG, exception); } } @@ -211,9 +213,9 @@ public static String writePretty(JsonNode objectNode) { * * @param objectNode the node to convert * @return the pretty printed JSON string representing the given node - * @throws JsonProcessingException in case the given node cannot be converted + * @throws JacksonException in case the given node cannot be converted */ - public static String tryWritePretty(JsonNode objectNode) throws JsonProcessingException { + public static String tryWritePretty(JsonNode objectNode) throws JacksonException { return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(objectNode); } @@ -532,10 +534,10 @@ public static Optional tryValueString(@Nonnull JsonNode jsonNode, String return Optional.empty(); } if (node.isNumber() || node.isBoolean()) { - return Optional.of(node.asText()); + return Optional.of(node.asString()); } - if (node.isTextual()) { - return Optional.of(node.textValue()); + if (node.isString()) { + return Optional.of(node.stringValue()); } return Optional.empty(); } @@ -557,8 +559,8 @@ public static Amount getValueAmount(@Nonnull JsonNode jsonNode, String fieldName if (node.isNumber()) { return Amount.ofRounded(node.decimalValue()); } - if (node.isTextual()) { - return Amount.ofMachineString(node.textValue()); + if (node.isString()) { + return Amount.ofMachineString(node.stringValue()); } if (node.isPojo()) { return tryGetFromPojoNode(node, Amount.class).orElse(Amount.NOTHING); @@ -580,11 +582,11 @@ public static Optional tryValueDate(@Nonnull JsonNode jsonNode, Strin if (node != null && node.isPojo()) { return tryGetFromPojoNode(node, LocalDate.class); } - if (node == null || node.isNull() || !node.isTextual()) { + if (node == null || node.isNull() || !node.isString()) { return Optional.empty(); } try { - return Optional.of(LocalDate.parse(node.textValue(), DateTimeFormatter.ISO_DATE)); + return Optional.of(LocalDate.parse(node.stringValue(), DateTimeFormatter.ISO_DATE)); } catch (DateTimeParseException exception) { return Optional.empty(); } @@ -605,11 +607,11 @@ public static Optional tryValueDateTime(@Nonnull JsonNode jsonNod if (node != null && node.isPojo()) { return tryGetFromPojoNode(node, LocalDateTime.class); } - if (node == null || node.isNull() || !node.isTextual()) { + if (node == null || node.isNull() || !node.isString()) { return Optional.empty(); } try { - return Optional.of(LocalDateTime.parse(node.textValue(), DateTimeFormatter.ISO_DATE_TIME)); + return Optional.of(LocalDateTime.parse(node.stringValue(), DateTimeFormatter.ISO_DATE_TIME)); } catch (DateTimeParseException exception) { return Optional.empty(); } diff --git a/src/test/kotlin/sirius/kernel/commons/JsonTest.kt b/src/test/kotlin/sirius/kernel/commons/JsonTest.kt index 3453b848..e304f290 100644 --- a/src/test/kotlin/sirius/kernel/commons/JsonTest.kt +++ b/src/test/kotlin/sirius/kernel/commons/JsonTest.kt @@ -8,13 +8,13 @@ package sirius.kernel.commons -import com.fasterxml.jackson.core.JsonPointer -import com.fasterxml.jackson.core.JsonProcessingException -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.node.ArrayNode -import com.fasterxml.jackson.databind.node.ObjectNode import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Test +import tools.jackson.core.JacksonException +import tools.jackson.core.JsonPointer +import tools.jackson.databind.JsonNode +import tools.jackson.databind.node.ArrayNode +import tools.jackson.databind.node.ObjectNode import java.math.BigDecimal import java.time.LocalDate import java.time.LocalDateTime @@ -50,7 +50,7 @@ class JsonTest { val node = Json.parseObject(json) assertEquals(2, node.size()) assertEquals(123, node["foo"].asInt()) - assertEquals("baz", node["bar"].asText()) + assertEquals("baz", node["bar"].asString()) } @Test @@ -59,13 +59,13 @@ class JsonTest { val node = Json.parseObject(json) assertEquals(2, node.size()) assertEquals(123, node["foo"].asInt()) - assertEquals("baz", node["bar"].asText()) + assertEquals("baz", node["bar"].asString()) } @Test fun `exception is thrown when parsing invalid object`() { val invalidJson = "[1, 2, 3]" - assertThrows(JsonProcessingException::class.java) { + assertThrows(JacksonException::class.java) { Json.tryParseObject(invalidJson) } } @@ -83,7 +83,7 @@ class JsonTest { @Test fun `exception is thrown when parsing invalid array`() { val invalidJson = """{ "foo": 123, "bar": "baz" }""" - assertThrows(JsonProcessingException::class.java) { + assertThrows(JacksonException::class.java) { Json.tryParseArray(invalidJson) } } @@ -160,7 +160,7 @@ class JsonTest { val presentObject: Optional = Json.tryGetObjectAtIndex(node, 0) assertTrue(presentObject.isPresent) - assertEquals("Alice", presentObject.get().get("name").asText()) + assertEquals("Alice", presentObject.get().get("name").asString()) val notAnObject: Optional = Json.tryGetObjectAtIndex(node, 1) assertTrue(!notAnObject.isPresent) @@ -176,7 +176,7 @@ class JsonTest { val presentObject: Optional = Json.tryGetObject(node, "foo") assertTrue(presentObject.isPresent) - assertEquals("Alice", presentObject.get().get("name").asText()) + assertEquals("Alice", presentObject.get().get("name").asString()) val notAnObject: Optional = Json.tryGetObject(node, "bar") assertTrue(!notAnObject.isPresent) @@ -192,7 +192,7 @@ class JsonTest { val presentObject: Optional = Json.tryGetObjectAt(node, Json.createPointer("foo")) assertTrue(presentObject.isPresent) - assertEquals("Alice", presentObject.get().get("name").asText()) + assertEquals("Alice", presentObject.get().get("name").asString()) val notAnObject: Optional = Json.tryGetObjectAt(node, Json.createPointer("bar")) assertTrue(!notAnObject.isPresent) @@ -282,7 +282,7 @@ class JsonTest { @Test fun `tryGetArrayAt works with arrays as POJO Nodes`() { val node = Json.createObject() - .set("nested", Json.createObject().putPOJO("foo", listOf(1, 2, 3)).put("bar", 123)) + .set("nested", Json.createObject().putPOJO("foo", listOf(1, 2, 3)).put("bar", 123)) val presentArray: Optional = Json.tryGetArrayAt(node, Json.createPointer("nested/foo")) assertTrue(presentArray.isPresent) @@ -334,7 +334,7 @@ class JsonTest { val presentValue: Optional = Json.tryGetAt(node, JsonPointer.valueOf("/foo/0/name")) assertTrue(presentValue.isPresent) - assertEquals("Alice", presentValue.get().asText()) + assertEquals("Alice", presentValue.get().asString()) val nullValue: Optional = Json.tryGetAt(node, JsonPointer.valueOf("/bar")) assertTrue(!nullValue.isPresent) From 1b0cbc5c669f892e6bb46a6f249f027b0b1dbeb0 Mon Sep 17 00:00:00 2001 From: Sascha Bieberstein Date: Tue, 14 Jul 2026 11:08:51 +0200 Subject: [PATCH 3/4] Considers empty Amounts as empty during Jackson serialization Jackson 2 serialized Amount via its @JsonValue accessor, whose JsonValueSerializer treated a null accessor result as empty, so that @JsonInclude(NON_EMPTY) suppressed properties holding empty amounts. The explicit serializer introduced in the Jackson 3 migration only replicated the value output, causing such properties to be written as null instead of being omitted. Overrides isEmpty() in Amount.JacksonSerializer to treat NOTHING as empty, restoring the Jackson 2 behavior. This surfaced in sellsite, where a model class annotated with @JsonInclude(NON_EMPTY) suddenly emitted "maxValue": null entries that Jackson 2 omitted. Assisted-by: Claude Fable 5 Fixes: SIRI-1149 --- src/main/java/sirius/kernel/commons/Amount.java | 7 +++++++ src/test/kotlin/sirius/kernel/commons/JsonTest.kt | 12 ++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/main/java/sirius/kernel/commons/Amount.java b/src/main/java/sirius/kernel/commons/Amount.java index 29a01114..01cd2806 100644 --- a/src/main/java/sirius/kernel/commons/Amount.java +++ b/src/main/java/sirius/kernel/commons/Amount.java @@ -303,6 +303,13 @@ public void serialize(Amount value, JsonGenerator generator, SerializationContex generator.writeNumber(roundedAmount); } } + + /// Considers [#NOTHING] as empty, so that `@JsonInclude(NON_EMPTY)` suppresses empty amounts + /// like the `@JsonValue` based serialization of Jackson 2 did. + @Override + public boolean isEmpty(SerializationContext context, Amount value) { + return value == null || value.isEmpty(); + } } @Override diff --git a/src/test/kotlin/sirius/kernel/commons/JsonTest.kt b/src/test/kotlin/sirius/kernel/commons/JsonTest.kt index e304f290..3ad18c5c 100644 --- a/src/test/kotlin/sirius/kernel/commons/JsonTest.kt +++ b/src/test/kotlin/sirius/kernel/commons/JsonTest.kt @@ -8,6 +8,7 @@ package sirius.kernel.commons +import com.fasterxml.jackson.annotation.JsonInclude import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Test import tools.jackson.core.JacksonException @@ -431,6 +432,17 @@ class JsonTest { assertEquals("1.23456789", Json.MAPPER.writeValueAsString(Amount.ofRounded(BigDecimal("1.23456789")))) } + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private class NonEmptyAmountHolder { + @Suppress("unused") + val amount: Amount = Amount.NOTHING + } + + @Test + fun `empty Amount is suppressed by JsonInclude NON_EMPTY like in Jackson 2`() { + assertEquals("{}", Json.MAPPER.writeValueAsString(NonEmptyAmountHolder())) + } + @Test fun `Read Amount from POJONode`() { val inputAmount = Amount.ofRounded(BigDecimal("1.23456789")) From 14fe8df6f6fe206c990222a83d32387d82926fee Mon Sep 17 00:00:00 2001 From: Sascha Bieberstein Date: Tue, 14 Jul 2026 11:51:02 +0200 Subject: [PATCH 4/4] Add tests documenting the Jackson scalar accessor behaviour This seems a bit quirky so it seems like a good idea to document his and also as a guard against undocumented behaviour changes (which the maintainers are known for). Assisted-by: Claude Fable 5 Fixes: SIRI-1149 --- .../kotlin/sirius/kernel/commons/JsonTest.kt | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/src/test/kotlin/sirius/kernel/commons/JsonTest.kt b/src/test/kotlin/sirius/kernel/commons/JsonTest.kt index 3ad18c5c..3891a83d 100644 --- a/src/test/kotlin/sirius/kernel/commons/JsonTest.kt +++ b/src/test/kotlin/sirius/kernel/commons/JsonTest.kt @@ -14,6 +14,7 @@ import org.junit.jupiter.api.Test import tools.jackson.core.JacksonException import tools.jackson.core.JsonPointer import tools.jackson.databind.JsonNode +import tools.jackson.databind.exc.JsonNodeException import tools.jackson.databind.node.ArrayNode import tools.jackson.databind.node.ObjectNode import java.math.BigDecimal @@ -443,6 +444,102 @@ class JsonTest { assertEquals("{}", Json.MAPPER.writeValueAsString(NonEmptyAmountHolder())) } + /** + * The following tests document how the Jackson 3 scalar accessors behave for filled, non-matching, + * null and missing properties, as this differs notably from Jackson 2 whose accessors (asText(), + * textValue(), longValue(), ...) leniently returned "", null, 0 or false for all null/missing/mismatch + * cases: + *

    + *
  • xxxValue() is strict: any missing property or type mismatch throws; only stringValue() + * treats an explicit JSON null as a legitimate value and returns null.
  • + *
  • asXxx() coerces: missing properties and JSON null leniently yield "", 0 or false, and + * convertible values (numeric strings, number-to-boolean, ...) are converted - but a failed + * coercion of an actual value throws.
  • + *
  • the default-taking variants (xxxValue(default), asXxx(default)) and the Optional variants + * (xxxValueOpt()) never throw and correspond to the lenient Jackson 2 behavior.
  • + *
+ */ + private val accessorTestNode = + Json.parseObject("""{ "string": "foo", "number": 42, "numberString": "42", "bool": true, "null": null }""") + + @Test + fun `documents string accessor behavior for filled, mismatching, null and missing properties`() { + // filled, matching: value is returned + assertEquals("foo", accessorTestNode.path("string").stringValue()) + assertEquals("foo", accessorTestNode.path("string").asString()) + + // filled, non-matching: stringValue() is strict, asString() coerces scalars but not containers + assertThrows(JsonNodeException::class.java) { accessorTestNode.path("number").stringValue() } + assertEquals("42", accessorTestNode.path("number").asString()) + assertThrows(JsonNodeException::class.java) { accessorTestNode.asString() } + + // JSON null: a legitimate value for stringValue(), an empty string for asString() + assertEquals(null, accessorTestNode.path("null").stringValue()) + assertEquals("", accessorTestNode.path("null").asString()) + + // missing: stringValue() throws, asString() coerces leniently + assertThrows(JsonNodeException::class.java) { accessorTestNode.path("missing").stringValue() } + assertEquals("", accessorTestNode.path("missing").asString()) + + // default-taking and Optional variants never throw + assertEquals(null, accessorTestNode.path("missing").stringValue(null)) + assertEquals("fallback", accessorTestNode.path("missing").stringValue("fallback")) + assertEquals("foo", accessorTestNode.path("string").stringValueOpt().get()) + assertTrue(accessorTestNode.path("missing").stringValueOpt().isEmpty) + } + + @Test + fun `documents numeric accessor behavior for filled, mismatching, null and missing properties`() { + // filled, matching: value is returned + assertEquals(42L, accessorTestNode.path("number").longValue()) + assertEquals(42L, accessorTestNode.path("number").asLong()) + + // filled, non-matching: longValue() is strict even for numeric strings, asLong() converts + // them but throws for non-numeric values + assertThrows(JsonNodeException::class.java) { accessorTestNode.path("numberString").longValue() } + assertEquals(42L, accessorTestNode.path("numberString").asLong()) + assertThrows(JsonNodeException::class.java) { accessorTestNode.path("string").asLong() } + + // JSON null and missing: longValue() cannot represent them and throws, asLong() yields 0 + assertThrows(JsonNodeException::class.java) { accessorTestNode.path("null").longValue() } + assertThrows(JsonNodeException::class.java) { accessorTestNode.path("missing").longValue() } + assertEquals(0L, accessorTestNode.path("null").asLong()) + assertEquals(0L, accessorTestNode.path("missing").asLong()) + + // default-taking and Optional variants never throw + assertEquals(0L, accessorTestNode.path("missing").longValue(0L)) + assertEquals(-1L, accessorTestNode.path("null").longValue(-1L)) + assertEquals(42L, accessorTestNode.path("number").longValueOpt().asLong) + assertTrue(accessorTestNode.path("null").longValueOpt().isEmpty) + assertTrue(accessorTestNode.path("missing").longValueOpt().isEmpty) + } + + @Test + fun `documents boolean accessor behavior for filled, mismatching, null and missing properties`() { + // filled, matching: value is returned + assertEquals(true, accessorTestNode.path("bool").booleanValue()) + assertEquals(true, accessorTestNode.path("bool").asBoolean()) + + // filled, non-matching: booleanValue() only accepts actual booleans; asBoolean() converts + // "true"/"false" strings and numbers (0 = false, everything else = true) but throws for + // other strings + assertThrows(JsonNodeException::class.java) { accessorTestNode.path("number").booleanValue() } + assertEquals(true, accessorTestNode.path("number").asBoolean()) + assertThrows(JsonNodeException::class.java) { accessorTestNode.path("string").asBoolean() } + + // JSON null and missing: booleanValue() throws, asBoolean() yields false + assertThrows(JsonNodeException::class.java) { accessorTestNode.path("null").booleanValue() } + assertThrows(JsonNodeException::class.java) { accessorTestNode.path("missing").booleanValue() } + assertEquals(false, accessorTestNode.path("null").asBoolean()) + assertEquals(false, accessorTestNode.path("missing").asBoolean()) + + // default-taking and Optional variants never throw + assertEquals(true, accessorTestNode.path("missing").booleanValue(true)) + assertEquals(false, accessorTestNode.path("null").booleanValue(false)) + assertEquals(true, accessorTestNode.path("bool").booleanValueOpt().get()) + assertTrue(accessorTestNode.path("missing").booleanValueOpt().isEmpty) + } + @Test fun `Read Amount from POJONode`() { val inputAmount = Amount.ofRounded(BigDecimal("1.23456789"))