DeductionWithAbstractSubtype4708Test.java

package tools.jackson.databind.jsontype;

import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;

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

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

/**
 * Tests for [databind#4708]: DEDUCTION mode should ignore abstract classes
 * and interfaces since they cannot be instantiated.
 */
public class DeductionWithAbstractSubtype4708Test extends DatabindTestUtil
{
    // Simulating Kotlin sealed class hierarchy with abstract intermediate class
    @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION)
    @JsonSubTypes({
        @JsonSubTypes.Type(Ingredient.AbstractItemById.class), // Abstract class registered!
        @JsonSubTypes.Type(Ingredient.ItemById.class),
        @JsonSubTypes.Type(Ingredient.ItemByTag.class)
    })
    sealed interface Ingredient permits Ingredient.Item {

        @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION)
        @JsonSubTypes({
            @JsonSubTypes.Type(Ingredient.AbstractItemById.class), // Abstract class registered!
            @JsonSubTypes.Type(Ingredient.ItemById.class),
            @JsonSubTypes.Type(Ingredient.ItemByTag.class)
        })
        sealed interface Item extends Ingredient
            permits Ingredient.AbstractItemById, Ingredient.ItemByTag {
        }

        // Abstract class with properties - should be IGNORED during deduction
        // Previously this would cause signature conflicts
        non-sealed abstract class AbstractItemById implements Item {
            @JsonProperty("item")
            public String id;
            public int count = 1;

            public AbstractItemById() {}
            public AbstractItemById(String id, int count) {
                this.id = id;
                this.count = count;
            }
        }

        // Concrete implementation of the abstract class
        final class ItemById extends AbstractItemById {
            public ItemById() {}
            public ItemById(String id, int count) {
                super(id, count);
            }
        }

        // Another concrete class with different signature
        final class ItemByTag implements Item {
            @JsonProperty("tag")
            public String tag;
            public int count = 1;

            public ItemByTag() {}
            public ItemByTag(String tag, int count) {
                this.tag = tag;
                this.count = count;
            }
        }
    }

    private final ObjectMapper MAPPER = newJsonMapper();

    @Test
    public void testDeductionWithAbstractIntermediateClass() throws Exception
    {
        // Should deduce to ItemById, ignoring the abstract AbstractItemById
        String json1 = a2q("{'item':'minecraft:stone','count':64}");
        Ingredient result1 = MAPPER.readValue(json1, Ingredient.class);

        assertNotNull(result1);
        assertInstanceOf(Ingredient.ItemById.class, result1, "Should deserialize to concrete ItemById, not abstract class");
        Ingredient.ItemById item1 = (Ingredient.ItemById) result1;
        assertEquals("minecraft:stone", item1.id);
        assertEquals(64, item1.count);

        // Should deduce to ItemByTag
        String json2 = a2q("{'tag':'minecraft:logs','count':32}");
        Ingredient result2 = MAPPER.readValue(json2, Ingredient.class);

        assertNotNull(result2);
        assertInstanceOf(Ingredient.ItemByTag.class, result2);
        Ingredient.ItemByTag item2 = (Ingredient.ItemByTag) result2;
        assertEquals("minecraft:logs", item2.tag);
        assertEquals(32, item2.count);
    }

    @Test
    public void testDeductionWithItemInterface() throws Exception
    {
        // When deserializing as Item interface, should also work
        String json = a2q("{'item':'test','count':1}");

        JavaType itemType = MAPPER.constructType(Ingredient.Item.class);
        Ingredient.Item result = MAPPER.readValue(json, itemType);

        assertNotNull(result);
        assertInstanceOf(Ingredient.ItemById.class, result);
        assertEquals("test", ((Ingredient.ItemById) result).id);
    }

    // Simpler test case with just abstract class and concrete subclass
    @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION)
    @JsonSubTypes({
        @JsonSubTypes.Type(Animal.class),         // Abstract class registered!
        @JsonSubTypes.Type(ConcreteAnimal.class)
    })
    abstract static class Animal {
        public String name;
    }

    static class ConcreteAnimal extends Animal {
        public int age;
    }

    @Test
    public void testSimpleAbstractClassIgnored() throws Exception
    {
        // Abstract Animal should be ignored, only ConcreteAnimal should be considered
        String json = a2q("{'name':'Fido','age':5}");
        Animal result = MAPPER.readValue(json, Animal.class);

        assertNotNull(result);
        assertInstanceOf(ConcreteAnimal.class, result);
        assertEquals("Fido", result.name);
        assertEquals(5, ((ConcreteAnimal) result).age);
    }

    // Test with interface in the mix
    @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION)
    @JsonSubTypes({
        @JsonSubTypes.Type(Dog.class),
        @JsonSubTypes.Type(Cat.class)
    })
    interface Pet {
        // Interface should be ignored
    }

    static class Dog implements Pet {
        public String breed;
    }

    static class Cat implements Pet {
        public boolean indoor;
    }

    @Test
    public void testInterfaceIgnored() throws Exception
    {
        // Interface Pet should be ignored during fingerprinting
        String json1 = a2q("{'breed':'Labrador'}");
        Pet result1 = MAPPER.readValue(json1, Pet.class);

        assertNotNull(result1);
        assertInstanceOf(Dog.class, result1);
        assertEquals("Labrador", ((Dog) result1).breed);

        String json2 = a2q("{'indoor':true}");
        Pet result2 = MAPPER.readValue(json2, Pet.class);

        assertNotNull(result2);
        assertInstanceOf(Cat.class, result2);
        assertTrue(((Cat) result2).indoor);
    }

    // Test that the feature can be disabled to get old behavior
    @Test
    public void testFeatureCanBeDisabled() throws Exception
    {
        // When feature is disabled, abstract types participate in deduction
        // which causes signature conflicts (old buggy behavior)
        ObjectMapper mapper = jsonMapperBuilder()
            .disable(DeserializationFeature.IGNORE_ABSTRACT_TYPES_FOR_DEDUCTION)
            .build();

        String json = a2q("{'item':'minecraft:stone','count':64}");

        try {
            mapper.readValue(json, Ingredient.class);
            fail("Should have failed with signature conflict");
        } catch (InvalidDefinitionException e) {
            verifyException(e, "Subtypes");
            verifyException(e, "have the same signature");
            verifyException(e, "cannot be uniquely deduced");
            // Verify it mentions both the abstract class and concrete class
            verifyException(e, "AbstractItemById");
            verifyException(e, "ItemById");
        }
    }

    // Test that feature is enabled by default
    @Test
    public void testFeatureEnabledByDefault() throws Exception
    {
        ObjectMapper mapper = newJsonMapper();
        assertTrue(mapper.isEnabled(DeserializationFeature.IGNORE_ABSTRACT_TYPES_FOR_DEDUCTION),
            "IGNORE_ABSTRACT_TYPES_FOR_DEDUCTION should be enabled by default");
    }
}