MonthDeserializerTest.java

package tools.jackson.databind.ext.javatime.deser;

import java.time.Month;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAccessor;

import org.junit.jupiter.api.function.Executable;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.ValueSource;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.OptBoolean;

import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.MapperFeature;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.ObjectReader;
import tools.jackson.databind.cfg.CoercionAction;
import tools.jackson.databind.cfg.CoercionInputShape;
import tools.jackson.databind.cfg.DateTimeFeature;
import tools.jackson.databind.exc.InvalidFormatException;
import tools.jackson.databind.exc.MismatchedInputException;
import tools.jackson.databind.ext.javatime.DateTimeTestBase;
import tools.jackson.databind.ext.javatime.MockObjectConfiguration;
import tools.jackson.databind.json.JsonMapper;

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

public class MonthDeserializerTest extends DateTimeTestBase
{
    static class Wrapper {
        public Month value;

        public Wrapper(Month v) { value = v; }
        public Wrapper() { }
    }

    static class WrapperWithFormat {
        @JsonFormat(pattern = "MMM", locale = "en")
        public Month value;
    }

    @ParameterizedTest
    @EnumSource(Month.class)
    public void testDeserializationAsString01_oneBased(Month expectedMonth) throws Exception
    {
        int monthNum = expectedMonth.getValue();
        assertEquals(expectedMonth, readerForOneBased().readValue("\"" + monthNum + '"'));
    }

    @ParameterizedTest
    @EnumSource(Month.class)
    public void testDeserializationAsString01_zeroBased(Month expectedMonth) throws Exception
    {
        int monthNum = expectedMonth.ordinal();
        assertEquals(expectedMonth, readerForZeroBased().readValue("\"" + monthNum + '"'));
    }


    @ParameterizedTest
    @EnumSource(Month.class)
    public void testDeserializationAsString02_oneBased(Month month) throws Exception
    {
        assertEquals(month, readerForOneBased().readValue("\"" + month.name() + '"'));
    }

    @ParameterizedTest
    @EnumSource(Month.class)
    public void testDeserializationAsString02_zeroBased(Month month) throws Exception
    {
        assertEquals(month, readerForOneBased().readValue("\"" + month.name() + '"'));
    }

    @ParameterizedTest
    @CsvSource({
            "notamonth , 'Cannot deserialize value of type `java.time.Month` from String \"notamonth\": not one of known `Month` values:'",
            "JANUAR    , 'Cannot deserialize value of type `java.time.Month` from String \"JANUAR\": not one of known `Month` values:'",
            "march     , 'Cannot deserialize value of type `java.time.Month` from String \"march\": not one of known `Month` values:'",
            "0         , 'month number outside 1-12'",
            "13        , 'month number outside 1-12'",
    })
    public void testBadDeserializationAsString01_oneBased(String monthSpec, String expectedMessage) {
        String value = "\"" + monthSpec + '"';
        assertError(
            () -> readerForOneBased().readValue(value),
            InvalidFormatException.class,
            expectedMessage
        );
    }

    static void assertError(Executable codeToRun, Class<? extends Throwable> expectedException, String expectedMessage) {
        try {
            codeToRun.execute();
            fail(String.format("Expecting %s, but nothing was thrown!", expectedException.getName()));
        } catch (Throwable actualException) {
            if (!expectedException.isInstance(actualException)) {
                fail(String.format("Expecting exception of type %s, but %s was thrown instead", expectedException.getName(), actualException.getClass().getName()));
            }
            if (actualException.getMessage() == null || !actualException.getMessage().contains(expectedMessage)) {
                fail(String.format("Expecting exception with message containing: '%s', but the actual error message was:'%s'", expectedMessage, actualException.getMessage()));
            }
        }
    }

    private final ObjectMapper MAPPER = newMapper();

    @Test
    public void testDeserialization01_zeroBased() throws Exception
    {
        assertEquals(Month.FEBRUARY, readerForZeroBased().readValue("1"));
    }

    @Test
    public void testDeserialization01_oneBased() throws Exception
    {
        assertEquals(Month.JANUARY, readerForOneBased().readValue("1"));
    }

    @Test
    public void testDeserialization02_zeroBased() throws Exception
    {
        assertEquals(Month.SEPTEMBER, readerForZeroBased().readValue("\"8\""));
    }

    @Test
    public void testDeserialization02_oneBased() throws Exception
    {
        assertEquals(Month.AUGUST, readerForOneBased().readValue("\"8\""));
    }

    @Test
    public void testDeserializationWithTypeInfo01_oneBased() throws Exception
    {
        ObjectMapper MAPPER = JsonMapper.builder()
            .addMixIn(TemporalAccessor.class, MockObjectConfiguration.class)
            .enable(DateTimeFeature.ONE_BASED_MONTHS)
            .build();

        TemporalAccessor value = MAPPER.readValue("[\"java.time.Month\",11]", TemporalAccessor.class);
        assertEquals(Month.NOVEMBER, value);
    }

    @Test
    public void testDeserializationWithTypeInfo01_zeroBased() throws Exception
    {
        ObjectMapper MAPPER = JsonMapper.builder()
                .addMixIn(TemporalAccessor.class, MockObjectConfiguration.class)
                .disable(DateTimeFeature.ONE_BASED_MONTHS)
                .build();

        TemporalAccessor value = MAPPER.readValue("[\"java.time.Month\",\"11\"]", TemporalAccessor.class);
        assertEquals(Month.DECEMBER, value);
    }

    @Test
    public void testFormatAnnotation_zeroBased() throws Exception
    {
        Wrapper output = readerForZeroBased()
                .forType(Wrapper.class)
                .readValue("{\"value\":\"11\"}");
        assertEquals(new Wrapper(Month.DECEMBER).value, output.value);
    }

    @Test
    public void testFormatAnnotation_oneBased() throws Exception
    {
        Wrapper output = readerForOneBased()
                .forType(Wrapper.class)
                .readValue("{\"value\":\"11\"}");
        assertEquals(new Wrapper(Month.NOVEMBER).value, output.value);
    }

    /*
    /**********************************************************************
    /* Tests for empty string handling
    /**********************************************************************
     */

    @Test
    public void testDeserializeFromEmptyString() throws Exception
    {
        // Nulls are handled in general way, not by deserializer so they are ok
        Month m = MAPPER.readerFor(Month.class).readValue(" null ");
        assertNull(m);

        // Although coercion from empty String not enabled for Enums by default,
        // it IS for Scalars (when `MapperFeature.ALLOW_COERCION_OF_SCALARS` enabled
        // which it is by default). So need to disable it here:
        // (we no longer consider `Month` as LogicalType.Enum but LogicalType.DateTime)
        try {
            ObjectMapper strictMapper = mapperBuilder()
                    .disable(MapperFeature.ALLOW_COERCION_OF_SCALARS)
                    .build();
            Month result = strictMapper.readerFor(Month.class).readValue("\"\"");
            fail("Should not pass, but got: " + result);
        } catch (MismatchedInputException e) {
            verifyException(e, "Cannot coerce empty String");
        }
        // But can allow coercion of empty String to, say, null
        ObjectMapper emptyStringMapper = mapperBuilder()
                .withCoercionConfig(Month.class,
                        h -> h.setCoercion(CoercionInputShape.EmptyString, CoercionAction.AsNull))
                .build();
        m = emptyStringMapper.readerFor(Month.class).readValue("\"\"");
        assertNull(m);
    }

    /*
    /**********************************************************************
    /* Tests for numeric int input (VALUE_NUMBER_INT)
    /**********************************************************************
     */

    @ParameterizedTest
    @EnumSource(Month.class)
    public void testDeserializationAsInt_zeroBased(Month expectedMonth) throws Exception
    {
        int monthIndex = expectedMonth.ordinal();
        assertEquals(expectedMonth, readerForZeroBased().readValue(String.valueOf(monthIndex)));
    }

    @ParameterizedTest
    @EnumSource(Month.class)
    public void testDeserializationAsInt_oneBased(Month expectedMonth) throws Exception
    {
        int monthNum = expectedMonth.getValue();
        assertEquals(expectedMonth, readerForOneBased().readValue(String.valueOf(monthNum)));
    }

    @ParameterizedTest
    @ValueSource(ints = {-1, 12, 13, 100})
    public void testDeserializationAsIntOutOfRange_zeroBased(int invalidValue) throws Exception
    {
        assertError(
            () -> readerForZeroBased().readValue(String.valueOf(invalidValue)),
            MismatchedInputException.class,
            "month number outside 0-11 range"
        );
    }

    @ParameterizedTest
    @ValueSource(ints = {0, -1, 13, 100})
    public void testDeserializationAsIntOutOfRange_oneBased(int invalidValue) throws Exception
    {
        assertError(
            () -> readerForOneBased().readValue(String.valueOf(invalidValue)),
            MismatchedInputException.class,
            "month number outside 1-12 range"
        );
    }

    /*
    /**********************************************************************
    /* Tests for array handling
    /**********************************************************************
     */

    @Test
    public void testDeserializationAsEmptyArray() throws Exception
    {
        // Empty array returns null
        Month result = readerForOneBased().readValue("[]");
        assertNull(result);
    }

    @Test
    public void testDeserializationAsArrayWithIntValue() throws Exception
    {
        // Array with single int value (interpreted as 1-based month)
        Month result = readerForOneBased().readValue("[3]");
        assertEquals(Month.MARCH, result);
    }

    @Test
    public void testDeserializationAsArrayWithIntValue_zeroBased() throws Exception
    {
        // Array with single int value (0-based mode still uses Month.of for array)
        Month result = readerForZeroBased().readValue("[3]");
        assertEquals(Month.MARCH, result);
    }

    @Test
    public void testDeserializationAsArrayWithMoreThanOneElement() throws Exception
    {
        assertError(
            () -> readerForOneBased().readValue("[1, 2]"),
            MismatchedInputException.class,
            "Expected array to end"
        );
    }

    @Test
    public void testDeserializationAsArrayWithWrongToken() throws Exception
    {
        // Boolean in array without UNWRAP should fail with specific error
        assertError(
            () -> readerForOneBased().readValue("[true]"),
            MismatchedInputException.class,
            "Expected VALUE_NUMBER_INT"
        );
    }

    @Test
    public void testDeserializationAsArrayWithStringUnwrapDisabled() throws Exception
    {
        // String in array without UNWRAP_SINGLE_VALUE_ARRAYS should fail
        assertError(
            () -> readerForOneBased().readValue("[\"JANUARY\"]"),
            MismatchedInputException.class,
            "Expected VALUE_NUMBER_INT"
        );
    }

    @Test
    public void testDeserializationAsArrayWithFloatUnwrapDisabled() throws Exception
    {
        // Float in array without UNWRAP should fail
        assertError(
            () -> readerForOneBased().readValue("[1.5]"),
            MismatchedInputException.class,
            "Expected VALUE_NUMBER_INT"
        );
    }

    @Test
    public void testDeserializationAsArrayWithObjectUnwrapDisabled() throws Exception
    {
        // Object in array without UNWRAP should fail
        assertError(
            () -> readerForOneBased().readValue("[{}]"),
            MismatchedInputException.class,
            "Expected VALUE_NUMBER_INT"
        );
    }

    @Test
    public void testDeserializationAsArrayWithStringUnwrapEnabled() throws Exception
    {
        // String in array with UNWRAP_SINGLE_VALUE_ARRAYS should work
        Month result = MAPPER.readerFor(Month.class)
                .with(DeserializationFeature.UNWRAP_SINGLE_VALUE_ARRAYS)
                .with(DateTimeFeature.ONE_BASED_MONTHS)
                .readValue("[\"JANUARY\"]");
        assertEquals(Month.JANUARY, result);
    }

    @Test
    public void testDeserializationAsArrayWithNumericStringUnwrapEnabled() throws Exception
    {
        // Numeric string in array with UNWRAP_SINGLE_VALUE_ARRAYS
        Month result = MAPPER.readerFor(Month.class)
                .with(DeserializationFeature.UNWRAP_SINGLE_VALUE_ARRAYS)
                .with(DateTimeFeature.ONE_BASED_MONTHS)
                .readValue("[\"5\"]");
        assertEquals(Month.MAY, result);
    }

    @Test
    public void testDeserializationAsArrayWithMoreThanOneString() throws Exception
    {
        // More than one string with UNWRAP_SINGLE_VALUE_ARRAYS should fail
        assertError(
            () -> MAPPER.readerFor(Month.class)
                    .with(DeserializationFeature.UNWRAP_SINGLE_VALUE_ARRAYS)
                    .readValue("[\"JANUARY\", \"FEBRUARY\"]"),
            MismatchedInputException.class,
            "Attempted to unwrap"
        );
    }

    /*
    /**********************************************************************
    /* Tests for zero-based string parsing edge cases
    /**********************************************************************
     */

    @ParameterizedTest
    @CsvSource({
            "12  , 'month number outside 0-11'",
            "-1  , 'month number outside 0-11'",
            "100 , 'month number outside 0-11'",
    })
    public void testBadDeserializationAsString_zeroBasedOutOfRange(String monthSpec, String expectedMessage) {
        String value = q(monthSpec);
        assertError(
            () -> readerForZeroBased().readValue(value),
            InvalidFormatException.class,
            expectedMessage
        );
    }

    /*
    /**********************************************************************
    /* Tests for whitespace handling
    /**********************************************************************
     */

    @Test
    public void testDeserializationWithWhitespace() throws Exception
    {
        // Whitespace around month name should be trimmed
        Month result = readerForOneBased().readValue("\" JANUARY \"");
        assertEquals(Month.JANUARY, result);
    }

    @Test
    public void testDeserializationWithWhitespaceNumeric() throws Exception
    {
        // Whitespace around numeric value should be trimmed
        Month result = readerForOneBased().readValue("\" 6 \"");
        assertEquals(Month.JUNE, result);
    }

    /*
    /**********************************************************************
    /* Tests for unexpected tokens
    /**********************************************************************
     */

    @Test
    public void testDeserializationFromBoolean() throws Exception
    {
        // Bare boolean should be handled as unexpected token
        assertError(
            () -> readerForOneBased().readValue("true"),
            MismatchedInputException.class,
            "Unexpected token (VALUE_TRUE)"
        );
    }

    @Test
    public void testDeserializationFromFloat() throws Exception
    {
        // Bare float should be handled as unexpected token
        assertError(
            () -> readerForOneBased().readValue("1.5"),
            MismatchedInputException.class,
            "Unexpected token (VALUE_NUMBER_FLOAT)"
        );
    }

    @Test
    public void testDeserializationFromObject() throws Exception
    {
        // Object without scalar extraction should fail
        assertError(
            () -> readerForOneBased().readValue("{}"),
            MismatchedInputException.class,
            "Unexpected token (START_OBJECT)"
        );
    }

    /*
    /**********************************************************************
    /* Tests for custom DateTimeFormatter
    /**********************************************************************
     */

    @Test
    public void testDeserializationWithCustomFormat() throws Exception
    {
        WrapperWithFormat result = MAPPER.readValue("{\"value\":\"Jan\"}", WrapperWithFormat.class);
        assertEquals(Month.JANUARY, result.value);
    }

    @Test
    public void testDeserializationWithCustomFormatMarch() throws Exception
    {
        WrapperWithFormat result = MAPPER.readValue("{\"value\":\"Mar\"}", WrapperWithFormat.class);
        assertEquals(Month.MARCH, result.value);
    }

    @Test
    public void testDeserializationWithCustomFormatInvalid() throws Exception
    {
        assertError(
            () -> MAPPER.readValue("{\"value\":\"NotAMonth\"}", WrapperWithFormat.class),
            InvalidFormatException.class,
            "could not be parsed"
        );
    }

    static class WrapperWithFullMonthFormat {
        @JsonFormat(pattern = "MMMM", locale = "en")
        public Month value;
    }

    @Test
    public void testDeserializationWithFullMonthFormat() throws Exception
    {
        WrapperWithFullMonthFormat result = MAPPER.readValue(
                "{\"value\":\"January\"}", WrapperWithFullMonthFormat.class);
        assertEquals(Month.JANUARY, result.value);
    }

    /*
    /**********************************************************************
    /* Tests for leniency settings
    /**********************************************************************
     */

    static class WrapperStrict {
        @JsonFormat(lenient = OptBoolean.FALSE)
        public Month value;
    }

    static class WrapperLenient {
        @JsonFormat(lenient = OptBoolean.TRUE)
        public Month value;
    }

    @Test
    public void testWithLeniencyCreatesNewInstance() throws Exception
    {
        MonthDeserializer original = MonthDeserializer.INSTANCE;
        MonthDeserializer strict = original.withLeniency(false);
        assertNotSame(original, strict);
        assertFalse(strict.isLenient());
    }

    @Test
    public void testWithDateFormatCreatesNewInstance() throws Exception
    {
        MonthDeserializer original = MonthDeserializer.INSTANCE;
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MMM");
        MonthDeserializer withFormatter = original.withDateFormat(formatter);
        assertNotSame(original, withFormatter);
    }

    /*
    /**********************************************************************
    /* Helper methods
    /**********************************************************************
     */

    private ObjectReader readerForZeroBased() {
        return MAPPER
                .readerFor(Month.class)
                .without(DateTimeFeature.ONE_BASED_MONTHS);
    }

    private ObjectReader readerForOneBased() {
        return MAPPER
            .readerFor(Month.class)
            .with(DateTimeFeature.ONE_BASED_MONTHS);
    }
}