NumericShapeNanosecondsTest.java

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

import java.time.*;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import com.fasterxml.jackson.annotation.JsonFormat;

import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.cfg.DateTimeFeature;
import tools.jackson.databind.ext.javatime.DateTimeTestBase;

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

/**
 * Tests to verify that explicit numeric {@link JsonFormat.Shape} determines
 * whether timestamps are written and read as nanoseconds, overriding
 * {@link DateTimeFeature#WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS} and
 * {@link DateTimeFeature#READ_DATE_TIMESTAMPS_AS_NANOSECONDS}; and that
 * values round-trip regardless of global settings.
 */
public class NumericShapeNanosecondsTest extends DateTimeTestBase
{
    static final Duration DURATION = Duration.ofSeconds(3, 500_000_000);
    static final Instant INSTANT = Instant.ofEpochSecond(3, 500_000_000);
    static final LocalTime LOCAL_TIME = LocalTime.of(1, 2, 3, 500_000_000);
    static final LocalDateTime LOCAL_DATE_TIME = LocalDateTime.of(2026, 9, 23, 1, 2, 3, 500_000_000);
    static final OffsetTime OFFSET_TIME = OffsetTime.of(1, 2, 3, 500_000_000, ZoneOffset.UTC);
    static final OffsetDateTime OFFSET_DATE_TIME = OffsetDateTime.of(LOCAL_DATE_TIME, ZoneOffset.UTC);
    static final ZonedDateTime ZONED_DATE_TIME = ZonedDateTime.of(LOCAL_DATE_TIME, ZoneOffset.UTC);

    static class IntBean {
        @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT)
        public Duration duration = DURATION;
        @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT)
        public Instant instant = INSTANT;
        @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT)
        public LocalTime localTime = LOCAL_TIME;
        @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT)
        public LocalDateTime localDateTime = LOCAL_DATE_TIME;
        @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT)
        public OffsetTime offsetTime = OFFSET_TIME;
        @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT)
        public OffsetDateTime offsetDateTime = OFFSET_DATE_TIME;
        @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT)
        public ZonedDateTime zonedDateTime = ZONED_DATE_TIME;
    }

    static class FloatBean {
        @JsonFormat(shape = JsonFormat.Shape.NUMBER_FLOAT)
        public Duration duration = DURATION;
        @JsonFormat(shape = JsonFormat.Shape.NUMBER_FLOAT)
        public Instant instant = INSTANT;
        @JsonFormat(shape = JsonFormat.Shape.NUMBER_FLOAT)
        public LocalTime localTime = LOCAL_TIME;
        @JsonFormat(shape = JsonFormat.Shape.NUMBER_FLOAT)
        public LocalDateTime localDateTime = LOCAL_DATE_TIME;
        @JsonFormat(shape = JsonFormat.Shape.NUMBER_FLOAT)
        public OffsetTime offsetTime = OFFSET_TIME;
        @JsonFormat(shape = JsonFormat.Shape.NUMBER_FLOAT)
        public OffsetDateTime offsetDateTime = OFFSET_DATE_TIME;
        @JsonFormat(shape = JsonFormat.Shape.NUMBER_FLOAT)
        public ZonedDateTime zonedDateTime = ZONED_DATE_TIME;
    }

    // Unrelated `JsonFormat.Feature` must not cause shape to be dropped
    static class IntWithFeatureBean {
        @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT,
                with = JsonFormat.Feature.WRITE_DATES_WITH_ZONE_ID)
        public Duration duration = DURATION;
        @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT,
                with = JsonFormat.Feature.WRITE_DATES_WITH_ZONE_ID)
        public LocalTime localTime = LOCAL_TIME;
        @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT,
                with = JsonFormat.Feature.WRITE_DATES_WITH_ZONE_ID)
        public LocalDateTime localDateTime = LOCAL_DATE_TIME;
        @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT,
                with = JsonFormat.Feature.WRITE_DATES_WITH_ZONE_ID)
        public OffsetTime offsetTime = OFFSET_TIME;
    }

    // [databind#6239]: `NUMBER_INT` means "not as nanoseconds" regardless of global settings
    @ParameterizedTest(name = "globalNanos={0}")
    @ValueSource(booleans = { false, true })
    public void shapeIntRoundTrip(boolean globalNanos) throws Exception
    {
        ObjectMapper mapper = _mapper(globalNanos);
        String json = mapper.writeValueAsString(new IntBean());
        assertEquals("""
                {"duration":3500,"instant":3500,"localDateTime":[2026,9,23,1,2,3,500],\
                "localTime":[1,2,3,500],"offsetDateTime":1790125323500,\
                "offsetTime":[1,2,3,500,"Z"],"zonedDateTime":1790125323500}""", json);

        IntBean result = mapper.readValue(json, IntBean.class);
        assertEquals(DURATION, result.duration);
        assertEquals(INSTANT, result.instant);
        assertEquals(LOCAL_TIME, result.localTime);
        assertEquals(LOCAL_DATE_TIME, result.localDateTime);
        assertEquals(OFFSET_TIME, result.offsetTime);
        // compare as instants: deserialized zone depends on context time zone
        assertEquals(OFFSET_DATE_TIME.toInstant(), result.offsetDateTime.toInstant());
        assertEquals(ZONED_DATE_TIME.toInstant(), result.zonedDateTime.toInstant());
    }

    // [databind#6239]: `NUMBER_FLOAT` means "as nanoseconds" regardless of global settings
    @ParameterizedTest(name = "globalNanos={0}")
    @ValueSource(booleans = { false, true })
    public void shapeFloatRoundTrip(boolean globalNanos) throws Exception
    {
        ObjectMapper mapper = _mapper(globalNanos);
        String json = mapper.writeValueAsString(new FloatBean());
        assertEquals("""
                {"duration":3.500000000,"instant":3.500000000,\
                "localDateTime":[2026,9,23,1,2,3,500000000],\
                "localTime":[1,2,3,500000000],"offsetDateTime":1790125323.500000000,\
                "offsetTime":[1,2,3,500000000,"Z"],"zonedDateTime":1790125323.500000000}""", json);

        FloatBean result = mapper.readValue(json, FloatBean.class);
        assertEquals(DURATION, result.duration);
        assertEquals(INSTANT, result.instant);
        assertEquals(LOCAL_TIME, result.localTime);
        assertEquals(LOCAL_DATE_TIME, result.localDateTime);
        assertEquals(OFFSET_TIME, result.offsetTime);
        // compare as instants: deserialized zone depends on context time zone
        assertEquals(OFFSET_DATE_TIME.toInstant(), result.offsetDateTime.toInstant());
        assertEquals(ZONED_DATE_TIME.toInstant(), result.zonedDateTime.toInstant());
    }

    // [databind#6239]: shape must be retained even if other format features are specified
    @ParameterizedTest(name = "globalNanos={0}")
    @ValueSource(booleans = { false, true })
    public void shapeIntWithFeatureRoundTrip(boolean globalNanos) throws Exception
    {
        ObjectMapper mapper = _mapper(globalNanos);
        String json = mapper.writeValueAsString(new IntWithFeatureBean());
        assertEquals("""
                {"duration":3500,"localDateTime":[2026,9,23,1,2,3,500],\
                "localTime":[1,2,3,500],"offsetTime":[1,2,3,500,"Z"]}""", json);

        IntWithFeatureBean result = mapper.readValue(json, IntWithFeatureBean.class);
        assertEquals(DURATION, result.duration);
        assertEquals(LOCAL_TIME, result.localTime);
        assertEquals(LOCAL_DATE_TIME, result.localDateTime);
        assertEquals(OFFSET_TIME, result.offsetTime);
    }

    private ObjectMapper _mapper(boolean globalNanos) {
        return mapperBuilder()
                .configure(DateTimeFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS, globalNanos)
                .configure(DateTimeFeature.READ_DATE_TIMESTAMPS_AS_NANOSECONDS, globalNanos)
                .build();
    }
}