This is an automated email from the ASF dual-hosted git repository.

tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/main by this push:
     new 2d1e43ab32 TIKA-4843: fix per-request parse-context config for parsers 
with lock… (#3076)
2d1e43ab32 is described below

commit 2d1e43ab32a3f3e868bb9185119a26e4c6e4cf61
Author: Tim Allison <[email protected]>
AuthorDate: Wed Aug 26 17:55:18 2026 -0400

    TIKA-4843: fix per-request parse-context config for parsers with lock… 
(#3076)
---
 CHANGES.txt                                        |  10 ++
 .../ImageEmbeddingRuntimeConfigMergeTest.java      |  78 +++++++++++
 .../ocr/tess4j/Tess4JRuntimeConfigMergeTest.java   |  76 ++++++++++
 .../tika/parser/vlm/VLMRuntimeConfigMergeTest.java | 107 ++++++++++++++
 .../apache/tika/config/loader/JsonMergeUtils.java  |  39 +++++-
 .../tika/config/loader/JsonMergeUtilsTest.java     | 155 +++++++++++++++++++++
 6 files changed, 459 insertions(+), 6 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 1ec964cf50..13bed521c1 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,15 @@
 Release 4.1.0 - unreleased
 
+   * Fixed a bug that made per-request (parse-context) configuration unusable
+     for parsers that lock some config fields against caller modification --
+     Tess4J, the VLM parsers and the OpenAI image-embedding parser. Any such
+     config threw, including an empty one: the defaults were deep-copied
+     through their own setters, which the runtime config overrides to reject
+     caller input, so the copy tripped the parser's own guards before the
+     caller's JSON was read. Locked fields are still rejected when a caller
+     actually sets them. Configuration supplied at initialization time (the
+     "parsers" section) was never affected (TIKA-4843).
+     
    * OOXML parsers flag package parts that are unreachable through the OPC
      relationship graph: msoffice:has-unreferenced-parts (boolean) and
      msoffice:unreferenced-part-names. Purely structural (no bytes are
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ImageEmbeddingRuntimeConfigMergeTest.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ImageEmbeddingRuntimeConfigMergeTest.java
new file mode 100644
index 0000000000..9300600db6
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ImageEmbeddingRuntimeConfigMergeTest.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.inference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.config.ParseContextConfig;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * The non-blank {@code baseUrl} default trips this config's own "reject any 
non-empty
+ * value" guard when the merge copies the defaults through the setters 
(TIKA-4843).
+ */
+public class ImageEmbeddingRuntimeConfigMergeTest {
+
+    private static final String KEY = "openai-image-embedding-parser";
+
+    private ImageEmbeddingConfig runtime(String json) throws Exception {
+        ParseContext context = new ParseContext();
+        context.setJsonConfig(KEY, json);
+        return ParseContextConfig.getConfig(context, KEY,
+                ImageEmbeddingConfig.RuntimeConfig.class, new 
ImageEmbeddingConfig.RuntimeConfig());
+    }
+
+    @Test
+    public void testEmptyConfigMerges() throws Exception {
+        assertEquals(new ImageEmbeddingConfig().getBaseUrl(), 
runtime("{}").getBaseUrl());
+    }
+
+    @Test
+    public void testUnrelatedFieldMerges() throws Exception {
+        assertTrue(runtime("{\"skipEmbedding\": true}").isSkipEmbedding());
+    }
+
+    @Test
+    public void testCallerSetModelStillRejected() {
+        Exception e = assertThrows(Exception.class, () -> runtime("{\"model\": 
\"evil\"}"));
+        assertTrue(rootMessage(e).contains("Cannot modify model"), 
rootMessage(e));
+    }
+
+    @Test
+    public void testCallerSetBaseUrlStillRejected() {
+        Exception e = assertThrows(Exception.class,
+                () -> runtime("{\"baseUrl\": \"http://evil\"}";));
+        assertTrue(rootMessage(e).contains("Cannot modify baseUrl"), 
rootMessage(e));
+    }
+
+    @Test
+    public void testCallerSetApiKeyStillRejected() {
+        Exception e = assertThrows(Exception.class, () -> 
runtime("{\"apiKey\": \"evil\"}"));
+        assertTrue(rootMessage(e).contains("Cannot modify apiKey"), 
rootMessage(e));
+    }
+
+    private static String rootMessage(Throwable t) {
+        while (t.getCause() != null) {
+            t = t.getCause();
+        }
+        return String.valueOf(t.getMessage());
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JRuntimeConfigMergeTest.java
 
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JRuntimeConfigMergeTest.java
new file mode 100644
index 0000000000..5e232845dd
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/test/java/org/apache/tika/parser/ocr/tess4j/Tess4JRuntimeConfigMergeTest.java
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.ocr.tess4j;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.config.ParseContextConfig;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * Locked fields must be rejected only when the caller actually sets them. The 
merge
+ * clones the default first, and that clone must not trip the guards -- 
otherwise every
+ * per-request config throws, including {@code {}}.
+ */
+public class Tess4JRuntimeConfigMergeTest {
+
+    private Tess4JConfig runtime(String json) throws Exception {
+        ParseContext context = new ParseContext();
+        context.setJsonConfig("tess4j-parser", json);
+        return ParseContextConfig.getConfig(context, "tess4j-parser",
+                Tess4JConfig.RuntimeConfig.class, new 
Tess4JConfig.RuntimeConfig());
+    }
+
+    @Test
+    public void testEmptyConfigMerges() throws Exception {
+        assertEquals(new Tess4JConfig().getPoolSize(), 
runtime("{}").getPoolSize());
+    }
+
+    @Test
+    public void testUnrelatedFieldMerges() throws Exception {
+        assertTrue(runtime("{\"skipOcr\": true}").isSkipOcr());
+    }
+
+    @Test
+    public void testCallerSetPoolSizeStillRejected() {
+        Exception e = assertThrows(Exception.class, () -> 
runtime("{\"poolSize\": 7}"));
+        assertTrue(rootMessage(e).contains("Cannot modify poolSize"), 
rootMessage(e));
+    }
+
+    @Test
+    public void testCallerSetMaxImagePixelsStillRejected() {
+        Exception e = assertThrows(Exception.class, () -> 
runtime("{\"maxImagePixels\": 5}"));
+        assertTrue(rootMessage(e).contains("Cannot modify maxImagePixels"), 
rootMessage(e));
+    }
+
+    @Test
+    public void testCallerSetDataPathStillRejected() {
+        Exception e = assertThrows(Exception.class, () -> 
runtime("{\"dataPath\": \"/tmp/evil\"}"));
+        assertTrue(rootMessage(e).contains("Cannot modify dataPath"), 
rootMessage(e));
+    }
+
+    private static String rootMessage(Throwable t) {
+        while (t.getCause() != null) {
+            t = t.getCause();
+        }
+        return String.valueOf(t.getMessage());
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/VLMRuntimeConfigMergeTest.java
 
b/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/VLMRuntimeConfigMergeTest.java
new file mode 100644
index 0000000000..3a84951f3a
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/VLMRuntimeConfigMergeTest.java
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.vlm;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.config.ParseContextConfig;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * VLM's locked fields must reject caller input without the merge's own copy 
of the
+ * defaults tripping them first (TIKA-4843). Unlike Tess4J, several VLM 
defaults are
+ * non-blank, so the "reject any non-empty value" guards fire on the copy.
+ */
+public class VLMRuntimeConfigMergeTest {
+
+    private VLMOCRConfig runtime(String json, VLMOCRConfig init) throws 
Exception {
+        ParseContext context = new ParseContext();
+        context.setJsonConfig("vlm-ocr-parser", json);
+        return ParseContextConfig.getConfig(context, "vlm-ocr-parser",
+                VLMOCRConfig.RuntimeConfig.class, new 
VLMOCRConfig.RuntimeConfig(init));
+    }
+
+    private VLMOCRConfig runtime(String json) throws Exception {
+        return runtime(json, new VLMOCRConfig());
+    }
+
+    @Test
+    public void testEmptyConfigMerges() throws Exception {
+        assertEquals(new VLMOCRConfig().getBaseUrl(), 
runtime("{}").getBaseUrl());
+    }
+
+    @Test
+    public void testUnrelatedFieldMerges() throws Exception {
+        assertTrue(runtime("{\"skipOcr\": true}").isSkipOcr());
+    }
+
+    @Test
+    public void testCallerSetModelStillRejected() {
+        Exception e = assertThrows(Exception.class, () -> runtime("{\"model\": 
\"evil\"}"));
+        assertTrue(rootMessage(e).contains("Cannot modify model"), 
rootMessage(e));
+    }
+
+    @Test
+    public void testCallerSetBaseUrlStillRejected() {
+        Exception e = assertThrows(Exception.class,
+                () -> runtime("{\"baseUrl\": \"http://evil\"}";));
+        assertTrue(rootMessage(e).contains("Cannot modify baseUrl"), 
rootMessage(e));
+    }
+
+    @Test
+    public void testCallerSetApiKeyStillRejected() {
+        Exception e = assertThrows(Exception.class, () -> 
runtime("{\"apiKey\": \"evil\"}"));
+        assertTrue(rootMessage(e).contains("Cannot modify apiKey"), 
rootMessage(e));
+    }
+
+    @Test
+    public void testCallerSetAllowRuntimePromptStillRejected() {
+        Exception e = assertThrows(Exception.class,
+                () -> runtime("{\"allowRuntimePrompt\": true}"));
+        assertTrue(rootMessage(e).contains("Cannot modify 
allowRuntimePrompt"), rootMessage(e));
+    }
+
+    /**
+     * The maxTokens ceiling is init-time state with no getter. If the merge's 
copy resets it
+     * to the class default, a caller can raise maxTokens above what the 
operator configured.
+     */
+    @Test
+    public void testMaxTokensCeilingIsNotWidenedByTheCopy() {
+        VLMOCRConfig init = new VLMOCRConfig();
+        init.setMaxTokens(100);
+        Exception e = assertThrows(Exception.class, () -> 
runtime("{\"maxTokens\": 3000}", init));
+        assertTrue(rootMessage(e).contains("Cannot increase maxTokens"), 
rootMessage(e));
+    }
+
+    @Test
+    public void testMaxTokensBelowCeilingStillAllowed() throws Exception {
+        VLMOCRConfig init = new VLMOCRConfig();
+        init.setMaxTokens(100);
+        assertEquals(50, runtime("{\"maxTokens\": 50}", init).getMaxTokens());
+    }
+
+    private static String rootMessage(Throwable t) {
+        while (t.getCause() != null) {
+            t = t.getCause();
+        }
+        return String.valueOf(t.getMessage());
+    }
+}
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/JsonMergeUtils.java
 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/JsonMergeUtils.java
index be00ccb064..5f99bccc76 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/JsonMergeUtils.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/JsonMergeUtils.java
@@ -17,7 +17,11 @@
 package org.apache.tika.config.loader;
 
 import java.io.IOException;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
 
+import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility;
+import com.fasterxml.jackson.annotation.PropertyAccessor;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 
@@ -34,6 +38,31 @@ public final class JsonMergeUtils {
         // Utility class
     }
 
+    /**
+     * Field-access mappers used only to clone an already-valid default, keyed 
by the
+     * mapper they were derived from ({@code copy()} is expensive and the set 
of source
+     * mappers is tiny and long-lived).
+     * <p>
+     * The clone must not run through setters. Runtime-config subclasses 
override their
+     * setters to reject caller input -- often as "any non-empty value is a 
modification"
+     * -- so re-applying the default's own values through them throws, and the 
caller's
+     * JSON is never even reached. Copying by field also preserves init-time 
state that
+     * has no getter (e.g. VLMOCRConfig.RuntimeConfig's initMaxTokens 
baseline), which a
+     * serialization round-trip silently reset to the class default.
+     */
+    private static final Map<ObjectMapper, ObjectMapper> COPY_MAPPERS = new 
ConcurrentHashMap<>();
+
+    private static ObjectMapper copyMapper(ObjectMapper mapper) {
+        return COPY_MAPPERS.computeIfAbsent(mapper, m -> m.copy()
+                .setVisibility(PropertyAccessor.ALL, Visibility.NONE)
+                .setVisibility(PropertyAccessor.FIELD, Visibility.ANY));
+    }
+
+    /** Clones an already-validated default without invoking its setters. */
+    private static <T> T copyDefaults(ObjectMapper mapper, Class<T> 
configClass, T defaultConfig) {
+        return copyMapper(mapper).convertValue(defaultConfig, configClass);
+    }
+
     /**
      * Deserializes JSON and merges it with a default configuration object.
      * <p>
@@ -55,10 +84,9 @@ public final class JsonMergeUtils {
             return mapper.readValue(json, configClass);
         }
 
-        // Create a deep copy of defaultConfig to preserve immutability
-        T copy = mapper.convertValue(defaultConfig, configClass);
+        T copy = copyDefaults(mapper, configClass, defaultConfig);
 
-        // Merge JSON properties into the copy
+        // Only the caller's JSON goes through setters -- that is what 
validation guards are for
         return mapper.readerForUpdating(copy).readValue(json);
     }
 
@@ -79,11 +107,10 @@ public final class JsonMergeUtils {
             return mapper.treeToValue(node, configClass);
         }
 
-        // Create a deep copy of defaultConfig to preserve immutability
         @SuppressWarnings("unchecked")
-        T copy = mapper.convertValue(defaultConfig, (Class<T>) 
defaultConfig.getClass());
+        T copy = copyDefaults(mapper, (Class<T>) defaultConfig.getClass(), 
defaultConfig);
 
-        // Merge JSON properties into the copy
+        // Only the caller's JSON goes through setters -- that is what 
validation guards are for
         return mapper.readerForUpdating(copy).readValue(node);
     }
 
diff --git 
a/tika-serialization/src/test/java/org/apache/tika/config/loader/JsonMergeUtilsTest.java
 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/JsonMergeUtilsTest.java
new file mode 100644
index 0000000000..0bb75e8fcb
--- /dev/null
+++ 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/JsonMergeUtilsTest.java
@@ -0,0 +1,155 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.config.loader;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The defensive copy of the defaults must not run through the setters.
+ * Runtime-config subclasses override setters to reject caller input -- often 
as
+ * "any non-empty value is a modification" -- so re-applying the defaults' own
+ * values through them throws before the caller's JSON is ever read 
(TIKA-4843).
+ */
+public class JsonMergeUtilsTest {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    /** Stands in for a parser config: a normal field plus a locked one. */
+    public static class Config {
+        private String name = "default-name";
+        private int size = 5;
+
+        public String getName() {
+            return name;
+        }
+
+        public void setName(String name) {
+            this.name = name;
+        }
+
+        public int getSize() {
+            return size;
+        }
+
+        public void setSize(int size) {
+            this.size = size;
+        }
+    }
+
+    /** Stands in for a RuntimeConfig: {@code size} is locked, and a ceiling 
has no getter. */
+    public static class LockedConfig extends Config {
+        // no getter on purpose: init-time state a serialization round-trip 
would drop
+        private int ceiling = 4096;
+
+        public LockedConfig() {
+        }
+
+        public LockedConfig(int ceiling) {
+            this.ceiling = ceiling;
+        }
+
+        public int ceiling() {
+            return ceiling;
+        }
+
+        @Override
+        public void setSize(int size) {
+            throw new IllegalStateException("Cannot modify size at runtime");
+        }
+
+        @Override
+        public void setName(String name) {
+            // the "reject any non-empty value" shape, which breaks when the 
default is non-empty
+            if (name != null && !name.isEmpty()) {
+                throw new IllegalStateException("Cannot modify name at 
runtime");
+            }
+        }
+    }
+
+    @Test
+    public void testEmptyJsonDoesNotTripLockedSetters() throws Exception {
+        LockedConfig merged = JsonMergeUtils.mergeWithDefaults(
+                MAPPER, "{}", LockedConfig.class, new LockedConfig());
+        assertEquals("default-name", merged.getName());
+        assertEquals(5, merged.getSize());
+    }
+
+    @Test
+    public void testNonBlankDefaultDoesNotTripRejectNonEmptySetter() throws 
Exception {
+        LockedConfig defaults = new LockedConfig();
+        // the failing shape from TIKA-4843: a non-empty default re-applied 
through its own guard
+        JsonMergeUtils.mergeWithDefaults(MAPPER, "{}", LockedConfig.class, 
defaults);
+    }
+
+    @Test
+    public void testCallerSuppliedLockedFieldStillRejected() {
+        IOException e = assertThrows(IOException.class, () -> 
JsonMergeUtils.mergeWithDefaults(
+                MAPPER, "{\"size\": 9}", LockedConfig.class, new 
LockedConfig()));
+        assertTrue(rootMessage(e).contains("Cannot modify size"), 
rootMessage(e));
+    }
+
+    @Test
+    public void testInitTimeStateWithoutGetterSurvivesTheCopy() throws 
Exception {
+        LockedConfig merged = JsonMergeUtils.mergeWithDefaults(
+                MAPPER, "{}", LockedConfig.class, new LockedConfig(100));
+        // a serialize/deserialize round-trip would have reset this to the 
class default
+        assertEquals(100, merged.ceiling());
+    }
+
+    @Test
+    public void testJsonNodeOverloadBehavesTheSame() throws Exception {
+        LockedConfig merged = JsonMergeUtils.mergeWithDefaults(
+                MAPPER, MAPPER.readTree("{}"), LockedConfig.class, new 
LockedConfig(100));
+        assertEquals(100, merged.ceiling());
+        assertEquals("default-name", merged.getName());
+    }
+
+    @Test
+    public void testJsonNodeOverloadStillRejectsLockedField() {
+        assertThrows(IOException.class, () -> JsonMergeUtils.mergeWithDefaults(
+                MAPPER, MAPPER.readTree("{\"size\": 9}"), LockedConfig.class, 
new LockedConfig()));
+    }
+
+    @Test
+    public void testUnlockedConfigStillMergesNormally() throws Exception {
+        Config merged = JsonMergeUtils.mergeWithDefaults(
+                MAPPER, "{\"name\": \"override\"}", Config.class, new 
Config());
+        assertEquals("override", merged.getName());
+        assertEquals(5, merged.getSize(), "unspecified fields keep their 
defaults");
+    }
+
+    @Test
+    public void testDefaultsObjectIsNotMutated() throws Exception {
+        Config defaults = new Config();
+        JsonMergeUtils.mergeWithDefaults(MAPPER, "{\"name\": \"override\"}", 
Config.class, defaults);
+        assertEquals("default-name", defaults.getName(), "the caller's 
defaults must not be touched");
+    }
+
+    private static String rootMessage(Throwable t) {
+        while (t.getCause() != null) {
+            t = t.getCause();
+        }
+        return String.valueOf(t.getMessage());
+    }
+}

Reply via email to