UnwrappedPropertyConflict2883Test.java

package tools.jackson.databind.struct;

import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonUnwrapped;

import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.PropertyNamingStrategies;
import tools.jackson.databind.exc.InvalidDefinitionException;
import tools.jackson.databind.testutil.DatabindTestUtil;

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

/**
 * Tests for [databind#2883]: detection of property name collisions
 * between unwrapped properties and regular bean properties.
 */
public class UnwrappedPropertyConflict2883Test extends DatabindTestUtil
{
    static class InnerB {
        public int ba = 3;
    }

    static class InnerD {
        public int da = 4;
    }

    static class OuterConflict {
        public InnerB b = new InnerB();
        @JsonUnwrapped
        public InnerC c = new InnerC();
    }

    static class InnerC {
        public InnerD b = new InnerD();
    }

    static class OuterNoConflict {
        public InnerB b = new InnerB();
        @JsonUnwrapped(prefix = "c_")
        public InnerC c = new InnerC();
    }

    static class OuterNoConflict2 {
        public String name = "test";
        @JsonUnwrapped
        public Location location = new Location(1, 2);
    }

    static class Location {
        public int x;
        public int y;

        public Location() { }
        public Location(int x, int y) {
            this.x = x;
            this.y = y;
        }
    }

    // Two unwrapped types that both produce a property named "id"
    static class TwoUnwrappedConflict {
        @JsonUnwrapped
        public HasId1 first = new HasId1();
        @JsonUnwrapped
        public HasId2 second = new HasId2();
    }

    static class HasId1 {
        public int id = 1;
    }

    static class HasId2 {
        public int id = 2;
    }

    // [databind#2883]: rename via @JsonProperty on inner field should be
    // respected when detecting conflicts
    static class OuterRenameConflict {
        public int id = 0;
        @JsonUnwrapped
        public RenamedToId inner = new RenamedToId();
    }

    static class RenamedToId {
        @JsonProperty("id")
        public int internalKey = 7;
    }

    // Inner field name collides with outer only AFTER snake-case naming strategy
    // ("fooBar" -> "foo_bar") is applied by the mapper.
    static class OuterNamingStrategyConflict {
        public int fooBar = 1;
        @JsonUnwrapped
        public HasFooBar inner = new HasFooBar();
    }

    static class HasFooBar {
        public int fooBar = 2;
    }

    // Self-referential @JsonUnwrapped: not a sensible model (structurally
    // infinite) but conflict-detection must not infinite-recurse or stack
    // overflow while building the serializer for such a type.
    static class SelfUnwrapped {
        public int id = 1;
        @JsonUnwrapped
        public SelfUnwrapped self;
    }

    // Nested unwrapping: Level1 has @JsonUnwrapped Level2 which itself has
    // @JsonUnwrapped Level3. Documents current single-level check scope.
    static class Level1 {
        public String a = "a";
        @JsonUnwrapped
        public Level2 l2 = new Level2();
    }

    static class Level2 {
        public String b = "b";
        @JsonUnwrapped
        public Level3 l3 = new Level3();
    }

    static class Level3 {
        public String c = "c";
    }

    private final ObjectMapper MAPPER = newJsonMapper();

    @Test
    public void testUnwrappedConflictDetected() throws Exception {
        InvalidDefinitionException ex = assertThrows(
                InvalidDefinitionException.class,
                () -> MAPPER.writeValueAsString(new OuterConflict()));
        verifyException(ex, "Conflict for type");
        verifyException(ex, "'b'");
        // Error should also identify the offending unwrapped type
        verifyException(ex, "InnerC");
        // [databind#6028]: ...as well as the type that declares the unwrapped mapping
        verifyException(ex, "OuterConflict");
    }

    @Test
    public void testUnwrappedNoConflictWithPrefix() throws Exception {
        String json = MAPPER.writeValueAsString(new OuterNoConflict());
        // Outer's own `b` stays as "b"; unwrapped InnerC's `b` becomes "c_b"
        assertEquals("{\"b\":{\"ba\":3},\"c_b\":{\"da\":4}}", json);
    }

    @Test
    public void testTwoUnwrappedConflictDetected() throws Exception {
        InvalidDefinitionException ex = assertThrows(
                InvalidDefinitionException.class,
                () -> MAPPER.writeValueAsString(new TwoUnwrappedConflict()));
        verifyException(ex, "unwrapped property");
        verifyException(ex, "'id'");
    }

    @Test
    public void testUnwrappedNoConflictDifferentNames() throws Exception {
        String json = MAPPER.writeValueAsString(new OuterNoConflict2());
        assertTrue(json.contains("\"name\":\"test\""));
        assertTrue(json.contains("\"x\":1"));
        assertTrue(json.contains("\"y\":2"));
    }

    // Conflict arises via `@JsonProperty` rename on the inner type; check must
    // use post-rename name (the one actually emitted), not the Java field name.
    @Test
    public void testConflictViaJsonPropertyRename() throws Exception {
        InvalidDefinitionException ex = assertThrows(
                InvalidDefinitionException.class,
                () -> MAPPER.writeValueAsString(new OuterRenameConflict()));
        verifyException(ex, "unwrapped property");
        verifyException(ex, "'id'");
    }

    // Conflict arises via PropertyNamingStrategy (both outer.fooBar and
    // unwrapped inner.fooBar become "foo_bar"); check must use post-transform name.
    @Test
    public void testConflictViaNamingStrategy() throws Exception {
        ObjectMapper mapper = jsonMapperBuilder()
                .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
                .build();
        InvalidDefinitionException ex = assertThrows(
                InvalidDefinitionException.class,
                () -> mapper.writeValueAsString(new OuterNamingStrategyConflict()));
        verifyException(ex, "unwrapped property");
        verifyException(ex, "'foo_bar'");
    }

    // Nested unwrapping without conflicts flattens all levels; documents that
    // the check traverses through each unwrapped serializer's final property names.
    @Test
    public void testNestedUnwrappedNoConflict() throws Exception {
        String json = MAPPER.writeValueAsString(new Level1());
        assertEquals("{\"a\":\"a\",\"b\":\"b\",\"c\":\"c\"}", json);
    }

    // Conflict-detection must tolerate self-referential @JsonUnwrapped without
    // infinite recursion. Serialization of such a value is nonsensical (would
    // infinite-loop), but building/resolving the serializer must not hang.
    // We give self=null here so we never actually try to serialize the cycle.
    @Test
    public void testSelfReferentialUnwrapDoesNotHang() throws Exception {
        String json = MAPPER.writeValueAsString(new SelfUnwrapped());
        assertTrue(json.contains("\"id\":1"));
    }

    // Inner field that would otherwise clash with outer is ignored via @JsonIgnore,
    // so it never appears in the inner serializer's effective properties and the
    // check must accept the configuration.
    static class OuterConflictResolvedByIgnore {
        public int id = 0;
        @JsonUnwrapped
        public IgnoredIdHolder inner = new IgnoredIdHolder();
    }

    static class IgnoredIdHolder {
        @JsonIgnore
        public int id = 99;
        public int other = 7;
    }

    @Test
    public void testNoConflictWhenUnwrappedPropertyIsIgnored() throws Exception {
        String json = MAPPER.writeValueAsString(new OuterConflictResolvedByIgnore());
        assertEquals("{\"id\":0,\"other\":7}", json);
    }

    // Regular "l3" at outer coexists with inner Level2 that has @JsonUnwrapped
    // Level3 (field named `l3`). The inner Level2's own `l3` name is not
    // emitted (it's unwrapped away), so this is NOT a real conflict.
    // Regression test: the check must not treat inner UnwrappingBeanPropertyWriter
    // field names as emitted names.
    static class OuterWithPhantomCollision {
        public String l3 = "outer";
        @JsonUnwrapped
        public Level2 inner = new Level2();
    }

    @Test
    public void testNoFalseConflictWithNestedUnwrappedFieldName() throws Exception {
        String json = MAPPER.writeValueAsString(new OuterWithPhantomCollision());
        assertTrue(json.contains("\"l3\":\"outer\""), json);
        assertTrue(json.contains("\"b\":\"b\""), json);
        assertTrue(json.contains("\"c\":\"c\""), json);
    }
}