ThrowableNamingStrategyTest.java

package tools.jackson.databind.exc;

import org.junit.jupiter.api.Test;

import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.PropertyNamingStrategies;
import tools.jackson.databind.PropertyNamingStrategy;
import tools.jackson.databind.annotation.JsonNaming;
import tools.jackson.databind.testutil.DatabindTestUtil;

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

/**
 * [databind#3497]: {@code ThrowableDeserializer} matched the standard {@link Throwable}
 * property names against hard-coded canonical names, using {@code equalsIgnoreCase()}.
 * That absorbs case-changing renames but cannot match snake- or kebab-cased ones, so
 * with such a {@link tools.jackson.databind.PropertyNamingStrategy} the multi-word
 * names ("localizedMessage", "stackTrace") went unrecognized.
 */
public class ThrowableNamingStrategyTest extends DatabindTestUtil
{
    @SuppressWarnings("serial")
    static class SnakeException extends RuntimeException {
        public SnakeException() { super(); }
        public SnakeException(String msg) { super(msg); }
    }

    // Class-level opt-OUT: `@JsonNaming` with the "use default" pseudo-value wins
    // over a mapper-level strategy, so these keep their canonical names
    @JsonNaming(PropertyNamingStrategy.class)
    @SuppressWarnings("serial")
    static class OptOutException extends RuntimeException {
        public OptOutException() { super(); }
        public OptOutException(String msg) { super(msg); }
    }

    // Class-level opt-IN, with no mapper-level strategy configured
    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
    @SuppressWarnings("serial")
    static class OptInException extends RuntimeException {
        public OptInException() { super(); }
        public OptInException(String msg) { super(msg); }
    }

    private final ObjectMapper SNAKE_MAPPER = jsonMapperBuilder()
            .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
            .build();

    // "localized_message" is skipped (like "localizedMessage" is with default naming),
    // not reported as an unknown property
    @Test
    public void localizedMessageSkippedUnderSnakeCase() throws Exception {
        ObjectMapper mapper = jsonMapperBuilder()
                .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
                .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
                .build();
        String json = """
                {"message":"the msg","localized_message":"the msg"}""";
        SnakeException ex = mapper.readValue(json, SnakeException.class);
        assertEquals("the msg", ex.getMessage());
    }

    // Control: same input with default naming has always worked
    @Test
    public void localizedMessageSkippedWithDefaultNaming() throws Exception {
        ObjectMapper mapper = jsonMapperBuilder()
                .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
                .build();
        String json = """
                {"message":"the msg","localizedMessage":"the msg"}""";
        SnakeException ex = mapper.readValue(json, SnakeException.class);
        assertEquals("the msg", ex.getMessage());
    }

    // Null "stack_trace" must be skipped, not passed to `setStackTrace(null)` (NPE)
    @Test
    public void nullStackTraceSkippedUnderSnakeCase() throws Exception {
        String json = """
                {"message":"the msg","stack_trace":null,"cause":null}""";
        SnakeException ex = SNAKE_MAPPER.readValue(json, SnakeException.class);
        assertEquals("the msg", ex.getMessage());
        assertNull(ex.getCause());
    }

    // Full round-trip through the renamed names
    @Test
    public void roundTripUnderSnakeCase() throws Exception {
        SnakeException input = new SnakeException("the msg");
        input.initCause(new RuntimeException("root"));
        String json = SNAKE_MAPPER.writeValueAsString(input);
        assertTrue(json.contains("\"stack_trace\""), "expected renamed 'stack_trace' in: " + json);

        SnakeException ex = SNAKE_MAPPER.readValue(json, SnakeException.class);
        assertEquals("the msg", ex.getMessage());
        assertNotNull(ex.getCause());
        assertEquals("root", ex.getCause().getMessage());
    }

    // [databind#6188] Class-level `@JsonNaming` opting out must win over the
    // mapper-level strategy: names stay canonical, so "stackTrace" (not
    // "stack_trace") is the one that must be recognized
    @Test
    public void classLevelOptOutBeatsMapperStrategy() throws Exception {
        String json = """
                {"message":"the msg","stackTrace":null,"cause":null}""";
        OptOutException ex = SNAKE_MAPPER.readValue(json, OptOutException.class);
        assertEquals("the msg", ex.getMessage());
        assertNull(ex.getCause());
    }

    // ...and class-level `@JsonNaming` applies even with no mapper-level strategy
    @Test
    public void classLevelStrategyAppliesWithoutMapperStrategy() throws Exception {
        ObjectMapper plain = newJsonMapper();
        String json = """
                {"message":"the msg","stack_trace":null,"cause":null}""";
        OptInException ex = plain.readValue(json, OptInException.class);
        assertEquals("the msg", ex.getMessage());
        assertNull(ex.getCause());
    }
}