MergeWithCreator1921Test.java

package tools.jackson.databind.deser.merge;

import java.util.*;

import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.annotation.*;

import tools.jackson.databind.*;
import tools.jackson.databind.exc.ValueInstantiationException;
import tools.jackson.databind.testutil.DatabindTestUtil;

import static org.junit.jupiter.api.Assertions.*;

// For [databind#1921]: when merge target type is fully immutable (all properties
// assigned via @JsonCreator, no setters/fields/any-setter), update-mode falls
// back to creator-based construction so that creator-enforced invariants fire ���
// instead of throwing InvalidDefinitionException for the missing fallback setter.
class MergeWithCreator1921Test extends DatabindTestUtil
{
    static class Account {
        @JsonMerge(value = OptBoolean.TRUE)
        private final Validity validity;

        @JsonCreator
        public Account(@JsonProperty(value = "validity", required = true) Validity validity) {
            this.validity = validity;
        }

        public Validity getValidity() {
            return validity;
        }
    }

    static class Validity {
        public static final String VALID_FROM_CANT_BE_NULL = "Valid from can't be null";
        public static final String VALID_TO_CANT_BE_BEFORE_VALID_FROM = "Valid to can't be before valid from";

        private final String _validFrom;
        private final String _validTo;

        @JsonCreator
        public Validity(@JsonProperty(value = "validFrom", required = true) String validFrom,
                        @JsonProperty("validTo") String validTo) {
            checkValidity(validFrom, validTo);

            this._validFrom = validFrom;
            this._validTo = validTo;
        }

        private void checkValidity(String from, String to) {
            Objects.requireNonNull(from, VALID_FROM_CANT_BE_NULL);
            if (to != null) {
                if (from.compareTo(to) > 0) {
                    throw new IllegalStateException(VALID_TO_CANT_BE_BEFORE_VALID_FROM);
                }
            }
        }

        public String getValidFrom() {
            return _validFrom;
        }

        public String getValidTo() {
            return _validTo;
        }
    }

    // Mergeable wrapper (no-arg ctor + setter) whose payload is fully-immutable,
    // used to exercise the fix at the inner-property level under a normal merge.
    static class MergeWrapper {
        @JsonMerge
        private OptionalFields payload;
        public OptionalFields getPayload() { return payload; }
        public void setPayload(OptionalFields p) { payload = p; }
    }

    static class OptionalFields {
        public final String a;
        public final String b;
        @JsonCreator
        public OptionalFields(@JsonProperty("a") String a, @JsonProperty("b") String b) {
            this.a = a;
            this.b = b;
        }
    }

    // Mixed: @JsonCreator param AND a setter/field for the same property.
    // The fallback setter means _hasUpdateableProperties() returns true,
    // so the new fix branch must NOT apply; update-mode keeps the existing
    // instance and assigns via the setter.
    static class CreatorPlusSetter {
        private String a;
        @JsonCreator
        public CreatorPlusSetter(@JsonProperty("a") String a) { this.a = a; }
        public String getA() { return a; }
        public void setA(String a) { this.a = a; }
    }

    // Creator-only + @JsonAnySetter. _anySetter != null flips
    // _hasUpdateableProperties() to true, so the fix must NOT apply;
    // any-setter can carry the update into the existing instance.
    static class CreatorPlusAnySetter {
        public final String a;
        public final Map<String, Object> extras = new LinkedHashMap<>();
        @JsonCreator
        public CreatorPlusAnySetter(@JsonProperty("a") String a) { this.a = a; }
        @JsonAnySetter
        public void put(String key, Object value) { extras.put(key, value); }
    }

    @Test
    void mergeWithCreator() throws Exception {
        final String JSON = "{ \"validity\": { \"validFrom\": \"2018-02-01\", \"validTo\": \"2018-01-31\" } }";

        final ObjectMapper mapper = newJsonMapper();

        try {
            mapper.readValue(JSON, Account.class);
            fail("Should not pass");
        } catch (ValueInstantiationException e) {
            verifyException(e, "Cannot construct");
            verifyException(e, Validity.VALID_TO_CANT_BE_BEFORE_VALID_FROM);
        }

        try {
            Account acc = new Account(new Validity("abc", "def"));
            mapper.readerForUpdating(acc)
                    .readValue(JSON);
            fail("Should not pass");
        } catch (ValueInstantiationException e) {
            verifyException(e, "Cannot construct");
            verifyException(e, Validity.VALID_TO_CANT_BE_BEFORE_VALID_FROM);
        }
    }

    // Partial JSON on fully-immutable merge target: narrow fix discards the
    // existing payload and rebuilds via creator, so fields absent from JSON
    // become null rather than inheriting the prior value. A fuller fix
    // (mirroring _deserializeRecordForUpdate for POJOs) would preserve them.
    @Test
    void partialMergeOfImmutableDiscardsExistingValues() throws Exception {
        final ObjectMapper mapper = newJsonMapper();
        MergeWrapper wrapper = new MergeWrapper();
        OptionalFields oldPayload = new OptionalFields("old-a", "old-b");
        wrapper.setPayload(oldPayload);

        MergeWrapper result = mapper.readerForUpdating(wrapper)
                .readValue("{\"payload\":{\"a\":\"new-a\"}}");

        assertSame(wrapper, result);
        assertNotSame(oldPayload, result.getPayload());
        assertEquals("new-a", result.getPayload().a);
        assertNull(result.getPayload().b);
    }

    // Empty JSON object on a fully-immutable merge target: the new branch is
    // positioned after the 3-arg path's `propName == null` short-circuit, so
    // empty `{}` still no-ops and the existing payload is preserved.
    @Test
    void emptyJsonOnImmutableMergeTargetPreservesExisting() throws Exception {
        final ObjectMapper mapper = newJsonMapper();
        MergeWrapper wrapper = new MergeWrapper();
        OptionalFields oldPayload = new OptionalFields("kept-a", "kept-b");
        wrapper.setPayload(oldPayload);

        MergeWrapper result = mapper.readerForUpdating(wrapper)
                .readValue("{\"payload\":{}}");

        assertSame(wrapper, result);
        assertSame(oldPayload, result.getPayload());
        assertEquals("kept-a", result.getPayload().a);
        assertEquals("kept-b", result.getPayload().b);
    }

    // Regression guard: when a creator property has a fallback setter, the
    // new branch must NOT trigger; existing instance is updated in-place.
    @Test
    void creatorWithFallbackSetterUpdatesExistingInstance() throws Exception {
        final ObjectMapper mapper = newJsonMapper();
        CreatorPlusSetter existing = new CreatorPlusSetter("old");
        CreatorPlusSetter result = mapper.readerForUpdating(existing)
                .readValue("{\"a\":\"new\"}");

        assertSame(existing, result);
        assertEquals("new", result.getA());
    }

    // Regression guard: @JsonAnySetter alone is enough to consider the type
    // updateable; existing instance is updated in-place via the any-setter.
    @Test
    void creatorWithAnySetterUpdatesExistingInstance() throws Exception {
        final ObjectMapper mapper = newJsonMapper();
        CreatorPlusAnySetter existing = new CreatorPlusAnySetter("kept");
        CreatorPlusAnySetter result = mapper.readerForUpdating(existing)
                .readValue("{\"x\":\"y\"}");

        assertSame(existing, result);
        assertEquals("kept", result.a);
        assertEquals("y", result.extras.get("x"));
    }
}