AlwaysWriteSubSecondDigitsTest.java

package tools.jackson.databind.ext.javatime;

import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collections;

import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.annotation.JsonFormat;

import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.cfg.DateTimeFeature;
import tools.jackson.databind.ext.javatime.ser.ZonedDateTimeSerializer;
import tools.jackson.databind.module.SimpleModule;

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

/**
 * Tests for {@link DateTimeFeature#ALWAYS_WRITE_SUBSECOND_DIGITS}.
 * <p>
 * Ported from the equivalent 2.x feature in
 * {@code jackson-modules-java8} (see
 * <a href="https://github.com/FasterXML/jackson-modules-java8/pull/386">modules-java8#386</a>,
 * fixing <a href="https://github.com/FasterXML/jackson-modules-java8/issues/76">modules-java8#76</a>).
 */
public class AlwaysWriteSubSecondDigitsTest extends DateTimeTestBase
{
    static class Wrapper {
        @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss")
        public OffsetDateTime value;

        Wrapper(OffsetDateTime v) { value = v; }
    }

    private final ObjectMapper MAPPER = newMapperBuilder()
            .enable(DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS)
            .build();

    private final ObjectMapper DEFAULT_MAPPER = newMapper();

    @Test
    public void testInstantZeroSubSecond() throws Exception
    {
        Instant value = Instant.parse("2017-09-14T04:28:48Z");
        // Default: sub-second field omitted entirely
        assertEquals(q("2017-09-14T04:28:48Z"), DEFAULT_MAPPER.writeValueAsString(value));
        // Enabled: zero-padded to millisecond precision
        assertEquals(q("2017-09-14T04:28:48.000Z"), MAPPER.writeValueAsString(value));
    }

    @Test
    public void testInstantHigherPrecisionNotTruncated() throws Exception
    {
        assertEquals(q("2017-09-14T04:28:48.100Z"),
                MAPPER.writeValueAsString(Instant.parse("2017-09-14T04:28:48.100Z")));
        assertEquals(q("2017-09-14T04:28:48.123456Z"),
                MAPPER.writeValueAsString(Instant.parse("2017-09-14T04:28:48.123456Z")));
        assertEquals(q("2017-09-14T04:28:48.123456789Z"),
                MAPPER.writeValueAsString(Instant.parse("2017-09-14T04:28:48.123456789Z")));
    }

    @Test
    public void testOffsetDateTime() throws Exception
    {
        OffsetDateTime value = OffsetDateTime.parse("2017-09-14T04:28:48+02:00");
        assertEquals(q("2017-09-14T04:28:48+02:00"), DEFAULT_MAPPER.writeValueAsString(value));
        assertEquals(q("2017-09-14T04:28:48.000+02:00"), MAPPER.writeValueAsString(value));

        // Note: the JDK ISO formatter renders 100 msec as ".1"; with the feature on,
        // width is stable at (at least) 3 digits
        OffsetDateTime millis = OffsetDateTime.parse("2017-09-14T04:28:48.100+02:00");
        assertEquals(q("2017-09-14T04:28:48.1+02:00"), DEFAULT_MAPPER.writeValueAsString(millis));
        assertEquals(q("2017-09-14T04:28:48.100+02:00"), MAPPER.writeValueAsString(millis));
    }

    @Test
    public void testZonedDateTime() throws Exception
    {
        ZonedDateTime value = ZonedDateTime.parse("2017-09-14T04:28:48+02:00[Europe/Budapest]");
        assertEquals(q("2017-09-14T04:28:48.000+02:00"), MAPPER.writeValueAsString(value));
    }

    @Test
    public void testZonedDateTimeWithZoneId() throws Exception
    {
        ObjectMapper mapper = newMapperBuilder()
                .enable(DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS)
                .enable(DateTimeFeature.WRITE_DATES_WITH_ZONE_ID)
                .build();
        ZonedDateTime value = ZonedDateTime.parse("2017-09-14T04:28:48+02:00[Europe/Budapest]");
        assertEquals(q("2017-09-14T04:28:48.000+02:00[Europe/Budapest]"),
                mapper.writeValueAsString(value));

        // Same path, feature disabled: zero sub-second is omitted, matching the JDK formatter
        ObjectMapper defaultZoneIdMapper = newMapperBuilder()
                .enable(DateTimeFeature.WRITE_DATES_WITH_ZONE_ID)
                .build();
        assertEquals(q("2017-09-14T04:28:48+02:00[Europe/Budapest]"),
                defaultZoneIdMapper.writeValueAsString(value));
    }

    @Test
    public void testLocalDateTime() throws Exception
    {
        LocalDateTime value = LocalDateTime.parse("2017-09-14T04:28:48");
        assertEquals(q("2017-09-14T04:28:48"), DEFAULT_MAPPER.writeValueAsString(value));
        assertEquals(q("2017-09-14T04:28:48.000"), MAPPER.writeValueAsString(value));

        // Seconds keep being written even when zero (as with the JDK ISO formatter)
        LocalDateTime noSeconds = LocalDateTime.parse("2017-09-14T04:28");
        assertEquals(q("2017-09-14T04:28:00"), DEFAULT_MAPPER.writeValueAsString(noSeconds));
        assertEquals(q("2017-09-14T04:28:00.000"), MAPPER.writeValueAsString(noSeconds));
    }

    // Feature must not leak into numeric timestamp serialization
    @Test
    public void testTimestampsUnaffected() throws Exception
    {
        ObjectMapper mapper = newMapperBuilder()
                .enable(DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS)
                .enable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS)
                .build();
        assertEquals("1505363328.000000000",
                mapper.writeValueAsString(Instant.parse("2017-09-14T04:28:48Z")));
    }

    // ... nor override an explicit `@JsonFormat` pattern
    @Test
    public void testExplicitPatternWins() throws Exception
    {
        assertEquals(a2q("{'value':'2017-09-14T04:28:48'}"),
                MAPPER.writeValueAsString(new Wrapper(OffsetDateTime.parse("2017-09-14T04:28:48Z"))));
    }

    // ... nor a default formatter passed to the serializer by the caller
    @Test
    public void testCallerProvidedDefaultFormatterWins() throws Exception
    {
        DateTimeFormatter df = DateTimeFormatter.ofPattern("yyyy_MM_dd'X'HH:mm:ss");
        ObjectMapper mapper = newMapperBuilder()
                .addModule(new SimpleModule()
                        .addSerializer(new ZonedDateTimeSerializer(df)))
                .enable(DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS)
                .build();
        assertEquals(q("2017_09_14X04:28:48"), mapper.writeValueAsString(
                ZonedDateTime.parse("2017-09-14T04:28:48+02:00[Europe/Budapest]")));
    }

    // ... including on the separate "write with Zone Id" path
    @Test
    public void testCallerProvidedDefaultFormatterWithZoneId() throws Exception
    {
        DateTimeFormatter df = DateTimeFormatter.ofPattern("yyyy_MM_dd'X'HH:mm:ss");
        ZonedDateTime value = ZonedDateTime.parse("2017-09-14T04:28:48+02:00[Europe/Budapest]");
        ObjectMapper mapper = newMapperBuilder()
                .addModule(new SimpleModule()
                        .addSerializer(new ZonedDateTimeSerializer(df)))
                .enable(DateTimeFeature.WRITE_DATES_WITH_ZONE_ID)
                .enable(DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS)
                .build();
        ObjectMapper defaultMapper = newMapperBuilder()
                .addModule(new SimpleModule()
                        .addSerializer(new ZonedDateTimeSerializer(df)))
                .enable(DateTimeFeature.WRITE_DATES_WITH_ZONE_ID)
                .build();
        assertEquals(defaultMapper.writeValueAsString(value),
                mapper.writeValueAsString(value));
        assertEquals(q("2017_09_14X04:28:48"), mapper.writeValueAsString(value));
    }

    // Map keys are written by separate key serializers, not affected by the feature
    @Test
    public void testMapKeysUnaffected() throws Exception
    {
        assertEquals(a2q("{'2017-09-14T04:28:48Z':1}"),
                MAPPER.writeValueAsString(
                        Collections.singletonMap(Instant.parse("2017-09-14T04:28:48Z"), 1)));
        assertEquals(a2q("{'2017-09-14T04:28:48':1}"),
                MAPPER.writeValueAsString(
                        Collections.singletonMap(LocalDateTime.parse("2017-09-14T04:28:48"), 1)));
    }

    // Extremes of `Instant` range fall outside `LocalDate` range and cannot be
    // written using Date/Time fields: must retain default handling, not fail
    @Test
    public void testInstantExtremes() throws Exception
    {
        assertEquals(q("-1000000000-01-01T00:00:00Z"),
                MAPPER.writeValueAsString(Instant.MIN));
        assertEquals(DEFAULT_MAPPER.writeValueAsString(Instant.MIN),
                MAPPER.writeValueAsString(Instant.MIN));
        assertEquals(q("+1000000000-12-31T23:59:59.999999999Z"),
                MAPPER.writeValueAsString(Instant.MAX));
        assertEquals(DEFAULT_MAPPER.writeValueAsString(Instant.MAX),
                MAPPER.writeValueAsString(Instant.MAX));

        // But values just inside `LocalDate` range are still padded as usual
        Instant maxLocal = LocalDate.MAX.atStartOfDay().toInstant(ZoneOffset.UTC);
        assertEquals(q("+999999999-12-31T00:00:00.000Z"),
                MAPPER.writeValueAsString(maxLocal));
        Instant minLocal = LocalDate.MIN.atStartOfDay().toInstant(ZoneOffset.UTC);
        assertEquals(q("-999999999-01-01T00:00:00.000Z"),
                MAPPER.writeValueAsString(minLocal));
    }

    // Values written with the feature on must still be readable
    @Test
    public void testRoundTrip() throws Exception
    {
        for (String raw : new String[] {
                "2017-09-14T04:28:48Z", "2017-09-14T04:28:48.123456789Z",
                "1970-01-01T00:00:00Z", "+10000-09-14T04:28:48Z", "-0100-09-14T04:28:48Z" }) {
            Instant value = Instant.parse(raw);
            String json = MAPPER.writeValueAsString(value);
            assertEquals(value, MAPPER.readValue(json, Instant.class),
                    "Round-trip failed for " + raw + " (serialized as " + json + ")");
        }
    }
}