JsonIdentityHashCodeIssue1546Test.java

package tools.jackson.databind.tofix;

import java.util.*;

import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.annotation.*;

import tools.jackson.databind.*;
import tools.jackson.databind.testutil.DatabindTestUtil;
import tools.jackson.databind.testutil.failure.JacksonTestFailureExpected;

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

/**
 * Test for [databind#1546]: Forward references with collections result in corrupt HashSet behavior
 * when objects are added to HashSet before their ID is fully deserialized.
 * <p>
 * The issue occurs when:
 * <ul>
 * <li>Objects have ID fields that are part of hashCode/equals</li>
 * <li>Objects are deserialized using @JsonIdentityInfo or @JsonBackReference</li>
 * <li>Objects are stored in HashSets</li>
 * </ul>
 * <p>
 * The problem: Objects are added to HashSets BEFORE their ID/reference fields are fully
 * set from JSON, causing the hashCode to be calculated with incomplete data. When the
 * ID/reference is later updated, the hashCode changes, breaking the HashSet contract.
 * This causes contains() and remove() operations to fail.
 */
public class JsonIdentityHashCodeIssue1546Test extends DatabindTestUtil
{
    // Base class with UUID-initialized id field
    static abstract class AbstractIdBase {
        protected String id = UUID.randomUUID().toString();

        public String getId() { return id; }
        public void setId(String id) { this.id = id; }

        @Override
        public int hashCode() {
            return Objects.hash(id);
        }

        @Override
        public boolean equals(Object obj) {
            if (this == obj) return true;
            if (obj == null || getClass() != obj.getClass()) return false;
            AbstractIdBase other = (AbstractIdBase) obj;
            return Objects.equals(id, other.id);
        }
    }

    @JsonIdentityInfo(
        property = "id",
        generator = ObjectIdGenerators.PropertyGenerator.class
    )
    static class Product extends AbstractIdBase {
        private String name;

        public Product() { }

        public Product(String id, String name) {
            this.id = id;
            this.name = name;
        }

        public String getName() { return name; }
        public void setName(String name) { this.name = name; }

        @Override
        public String toString() {
            return "Product{id='" + id + "', name='" + name + "'}";
        }
    }

    static class Order extends AbstractIdBase {
        @JsonIdentityReference(alwaysAsId = true)
        private Set<Product> items = new HashSet<>();

        public Order() { }

        public Order(String id) {
            this.id = id;
        }

        public Set<Product> getItems() { return items; }
        public void setItems(Set<Product> items) { this.items = items; }

        @Override
        public String toString() {
            return "Order{id='" + id + "', items=" + items.size() + "}";
        }
    }

    static class Root {
        private Set<Order> orders = new HashSet<>();

        @JsonIdentityInfo(
            property = "id",
            generator = ObjectIdGenerators.PropertyGenerator.class
        )
        private Set<Product> products = new HashSet<>();

        public Set<Order> getOrders() { return orders; }
        public void setOrders(Set<Order> orders) { this.orders = orders; }

        public Set<Product> getProducts() { return products; }
        public void setProducts(Set<Product> products) { this.products = products; }
    }

    // Test case demonstrating the HashSet corruption issue with @JsonIdentityReference
    // This test case is based on the original issue report and comments
    @Test
    public void testHashSetCorruptionWithIdentityReferences() throws Exception
    {
        ObjectMapper mapper = newJsonMapper();

        // Create test data
        Product p1 = new Product("product-1", "Apple");
        Product p2 = new Product("product-2", "Cherry");
        Product p3 = new Product("product-3", "Strawberry");

        Order order1 = new Order("order-1");
        order1.getItems().add(p1);
        order1.getItems().add(p2);
        order1.getItems().add(p3);

        Order order2 = new Order("order-2");
        order2.getItems().add(p2);
        order2.getItems().add(p3);

        Root root = new Root();
        root.getProducts().add(p1);
        root.getProducts().add(p2);
        root.getProducts().add(p3);
        root.getOrders().add(order1);
        root.getOrders().add(order2);

        // Serialize
        String json = mapper.writeValueAsString(root);
        // System.out.println("JSON: " + json);

        // Deserialize
        Root deserialized = mapper.readValue(json, Root.class);

        // Verify products were deserialized
        assertEquals(3, deserialized.getProducts().size());
        assertEquals(2, deserialized.getOrders().size());

        // Find an order with items
        Order deserializedOrder = deserialized.getOrders().stream()
            .filter(o -> !o.getItems().isEmpty())
            .findFirst()
            .orElseThrow(() -> new AssertionError("Expected at least one order with items"));

        // Find a product that should be in the order
        Product someProduct = deserializedOrder.getItems().iterator().next();

        // Would fail if: contains() returns false even though the product is in the set
        // because the product was added to the HashSet with a different hashCode
        // (before its id field was properly set from JSON)
        assertTrue(deserializedOrder.getItems().contains(someProduct),
            "HashSet should contain the product - but fails due to hashCode corruption");

        // Workaround: using stream().anyMatch() works because it doesn't rely on hashCode
        assertTrue(deserializedOrder.getItems().stream()
            .anyMatch(p -> p.equals(someProduct)),
            "Stream-based equality check works");

        // Could also fail
        Set<Product> itemsCopy = new HashSet<>(deserializedOrder.getItems());
        assertTrue(itemsCopy.remove(someProduct),
            "Should be able to remove product from HashSet - but fails due to hashCode corruption");
    }

    // ========================================================================
    // Simpler test case with parent-child back references
    // This demonstrates the issue when hashCode includes the parent reference
    // ========================================================================

    @JsonIdentityInfo(
        property = "id",
        generator = ObjectIdGenerators.PropertyGenerator.class
    )
    static class Parent extends AbstractIdBase {
        private String name;

        @JsonManagedReference
        private Set<Child> children = new HashSet<>();

        public Parent() { }

        public String getName() { return name; }
        public void setName(String name) { this.name = name; }

        public Set<Child> getChildren() { return children; }
        public void setChildren(Set<Child> children) { this.children = children; }
    }

    @JsonIdentityInfo(
        property = "id",
        generator = ObjectIdGenerators.PropertyGenerator.class
    )
    static class Child extends AbstractIdBase {
        private String name;

        @JsonBackReference
        private Parent parent;

        public Child() { }

        public String getName() { return name; }
        public void setName(String name) { this.name = name; }

        public Parent getParent() { return parent; }
        public void setParent(Parent parent) { this.parent = parent; }

        @Override
        public int hashCode() {
            // Include parent in hashCode - this is what triggers the issue
            return Objects.hash(id, parent);
        }

        @Override
        public boolean equals(Object obj) {
            if (this == obj) return true;
            if (obj == null || getClass() != obj.getClass()) return false;
            Child other = (Child) obj;
            return Objects.equals(id, other.id) && Objects.equals(parent, other.parent);
        }
    }

    // This test currently FAILS, demonstrating the bug
    // Child objects are added to HashSet before parent back-reference is set,
    // causing hashCode to change after insertion
    @JacksonTestFailureExpected
    @Test
    public void testHashSetCorruptionWithBackReferences() throws Exception
    {
        ObjectMapper mapper = newJsonMapper();

        // Create parent with children
        Parent parent = new Parent();
        parent.setId("parent-1");
        parent.setName("Parent");

        Child child1 = new Child();
        child1.setId("child-1");
        child1.setName("Child 1");
        child1.setParent(parent);

        Child child2 = new Child();
        child2.setId("child-2");
        child2.setName("Child 2");
        child2.setParent(parent);

        parent.getChildren().add(child1);
        parent.getChildren().add(child2);

        // Serialize
        String json = mapper.writeValueAsString(parent);

        // Deserialize
        Parent deserialized = mapper.readValue(json, Parent.class);

        // Verify children were deserialized
        assertEquals(2, deserialized.getChildren().size());

        // Get a child from the set
        Child deserializedChild = deserialized.getChildren().iterator().next();

        // THIS IS THE BUG: contains() fails because child's parent reference
        // was set AFTER the child was added to the HashSet, changing its hashCode
        assertTrue(deserialized.getChildren().contains(deserializedChild),
            "HashSet should contain the child - but fails because parent back-reference " +
            "was set after child was added to the HashSet");

        // Remove should also fail
        Set<Child> childrenCopy = new HashSet<>(deserialized.getChildren());
        assertTrue(childrenCopy.remove(deserializedChild),
            "Should be able to remove child from HashSet");
    }
}