UnwrappedCustomDeserCreator6001Test.java

package tools.jackson.databind.deser;

import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.Map;

import org.junit.jupiter.api.Test;

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

import tools.jackson.core.JsonParser;
import tools.jackson.databind.BeanProperty;
import tools.jackson.databind.DeserializationContext;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.ValueDeserializer;
import tools.jackson.databind.annotation.JsonDeserialize;
import tools.jackson.databind.node.ObjectNode;
import tools.jackson.databind.testutil.DatabindTestUtil;
import tools.jackson.databind.util.NameTransformer;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

// [databind#6001]: `@JsonUnwrapped` on a record (property-based-creator) component whose
// custom `ValueDeserializer` implements `unwrappingDeserializer(...)` but declares no
// property names. Before the fix, #650 buffered only properties recognized via
// `collectAllPropertyNamesTo(...)`, so such a custom unwrapper received an empty `{}`.
class UnwrappedCustomDeserCreator6001Test extends DatabindTestUtil
{
    record ConsentVersion(
            @JsonProperty String versionId,
            @JsonProperty String version,
            @JsonUnwrapped(prefix = "title")
            @JsonDeserialize(using = CapturingUnwrappedValueDeserializer.class)
            CapturingUnwrappedValue title,
            String scopeClassId) {}

    record CapturingUnwrappedValue(Map<String, Serializable> rawFields) {}

    static final class CapturingUnwrappedValueDeserializer
            extends ValueDeserializer<CapturingUnwrappedValue>
    {
        private final NameTransformer unwrapper;

        CapturingUnwrappedValueDeserializer() {
            this(null);
        }

        private CapturingUnwrappedValueDeserializer(NameTransformer unwrapper) {
            this.unwrapper = unwrapper;
        }

        @Override
        public CapturingUnwrappedValue deserialize(JsonParser p, DeserializationContext ctxt) {
            var node = ctxt.readTree(p);
            if (!(node instanceof ObjectNode objectNode)) {
                return new CapturingUnwrappedValue(Map.of());
            }
            var values = new LinkedHashMap<String, Serializable>();
            for (var entry : objectNode.properties()) {
                var key = (unwrapper != null) ? unwrapper.reverse(entry.getKey()) : entry.getKey();
                if (key == null || key.isBlank()) {
                    continue;
                }
                var value = ctxt.readTreeAsValue(entry.getValue(), Object.class);
                if (value instanceof Serializable serializableValue) {
                    values.put(key, serializableValue);
                }
            }
            return new CapturingUnwrappedValue(values);
        }

        @Override
        public ValueDeserializer<?> createContextual(DeserializationContext context, BeanProperty property) {
            return this;
        }

        @Override
        public ValueDeserializer<CapturingUnwrappedValue> unwrappingDeserializer(
                DeserializationContext context, NameTransformer unwrapper) {
            return new CapturingUnwrappedValueDeserializer(unwrapper);
        }
    }

    private final ObjectMapper MAPPER = newJsonMapper();

    @Test
    void unwrappedRecordCreatorPropertiesShouldReceiveFlattenedFields()
    {
        String json = """
                {
                  "versionId": "version-id",
                  "version": "1",
                  "titleEn": "title en",
                  "scopeClassId": "scope-class-id"
                }
                """;

        ConsentVersion result = MAPPER.readValue(json, ConsentVersion.class);

        assertTrue(result.title().rawFields().containsKey("En"),
                () -> "Expected title.rawFields() to contain key 'En', but got "
                        + result.title().rawFields());
        assertEquals("title en", result.title().rawFields().get("En"));
        assertEquals("version-id", result.versionId());
        assertEquals("scope-class-id", result.scopeClassId());
    }

    // Guard for [databind#650]: an opaque unwrapper routes all leftover unknowns to itself,
    // so FAIL_ON_UNKNOWN_PROPERTIES does NOT fire here (the unwrapper "owns" them) -- this is
    // the pre-#650 (3.0.x) behavior we are deliberately restoring for opaque unwrappers.
    @Test
    void opaqueUnwrapperAbsorbsUnknownsEvenWithFailOnUnknown()
    {
        ObjectMapper mapper = jsonMapperBuilder()
                .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
                .build();
        String json = """
                {
                  "versionId": "version-id",
                  "version": "1",
                  "titleEn": "title en",
                  "totallyUnknown": "x"
                }
                """;

        ConsentVersion result = mapper.readValue(json, ConsentVersion.class);
        assertEquals("title en", result.title().rawFields().get("En"));
    }
}