From ecb867caf0db14f1d656385ee5b85227bed9103e Mon Sep 17 00:00:00 2001 From: Stefan Jugelt Date: Sat, 20 Jun 2026 15:47:30 +0200 Subject: [PATCH 1/2] Timestamp with nanoseconds is accepted --- .../util/LocalDateTimeISO8601XmlAdapter.java | 2 +- .../netex/model/MarshalUnmarshalTest.java | 50 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/rutebanken/util/LocalDateTimeISO8601XmlAdapter.java b/src/main/java/org/rutebanken/util/LocalDateTimeISO8601XmlAdapter.java index 433e75f3..54894b7d 100644 --- a/src/main/java/org/rutebanken/util/LocalDateTimeISO8601XmlAdapter.java +++ b/src/main/java/org/rutebanken/util/LocalDateTimeISO8601XmlAdapter.java @@ -25,7 +25,7 @@ public class LocalDateTimeISO8601XmlAdapter extends XmlAdapter { private static final DateTimeFormatter formatter = new DateTimeFormatterBuilder().appendPattern("yyyy-MM-dd'T'HH:mm:ss") - .optionalStart().appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true).optionalEnd() + .optionalStart().appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true).optionalEnd() .optionalStart().appendPattern("XXXXX") .optionalEnd() diff --git a/src/test/java/org/rutebanken/netex/model/MarshalUnmarshalTest.java b/src/test/java/org/rutebanken/netex/model/MarshalUnmarshalTest.java index e2df73b6..21413c0c 100644 --- a/src/test/java/org/rutebanken/netex/model/MarshalUnmarshalTest.java +++ b/src/test/java/org/rutebanken/netex/model/MarshalUnmarshalTest.java @@ -434,4 +434,54 @@ void fragmentShouldNotContainNetexNamespace() throws Exception { assertFalse(xml.contains(netexNamespace)); } + @Test + void unmarshalPublicationDeliveryAndVerifyDateTimeNanoSeconds() throws JAXBException { + + String xml = "" + + "" + + " 2016-05-18T15:00:00.0123456+01:00" + + " NHR" + + " " + + " " + + " " + + " " + + " " + + " " + + " 10.8577903" + + " 59.910579" + + " " + + " " + + " Krokstien " + + " bus" + + " onstreetBus" + + " " + + " " + + " " + + " " + + " 10.8577903" + + " 59.910579" + + " " + + " " + + " outdoors" + + " wellLit" + + " busStop" + + " " + + " " + + " " + + " " + + " " + + " " + + ""; + + Unmarshaller unmarshaller = jaxbContext.createUnmarshaller(); + + @SuppressWarnings("unchecked") + JAXBElement jaxbElement = (JAXBElement) unmarshaller + .unmarshal(new ByteArrayInputStream(xml.getBytes())); + PublicationDeliveryStructure actual = jaxbElement.getValue(); + + System.out.println(actual.getPublicationTimestamp()); + assertThat(actual.getPublicationTimestamp().getHour()).isEqualTo(15); + } + } From 8cb242c3804db5bbce8309c44a4bfdbd82ad21e0 Mon Sep 17 00:00:00 2001 From: Stefan Jugelt Date: Sat, 20 Jun 2026 17:04:55 +0200 Subject: [PATCH 2/2] Timestamp with nanoseconds is accepted --- .../util/LocalDateTimeISO8601XmlAdapter.java | 152 ++++++++++-- .../netex/model/MarshalUnmarshalTest.java | 234 ++++++++++++++++-- 2 files changed, 334 insertions(+), 52 deletions(-) diff --git a/src/main/java/org/rutebanken/util/LocalDateTimeISO8601XmlAdapter.java b/src/main/java/org/rutebanken/util/LocalDateTimeISO8601XmlAdapter.java index 54894b7d..abafa70d 100644 --- a/src/main/java/org/rutebanken/util/LocalDateTimeISO8601XmlAdapter.java +++ b/src/main/java/org/rutebanken/util/LocalDateTimeISO8601XmlAdapter.java @@ -17,34 +17,136 @@ import jakarta.xml.bind.annotation.adapters.XmlAdapter; import java.time.LocalDateTime; -import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatterBuilder; import java.time.temporal.ChronoField; +import java.time.format.DateTimeParseException; +import java.time.format.DecimalStyle; +import java.util.List; +import java.util.regex.Pattern; + +/** + * XmlAdapter that (un)marshals {@link LocalDateTime} to/from a relaxed set of + * ISO-8601-like string representations. + * + * Supported input variations: + * - Date-only: 2023-01-01 + * - 'T' or space as separator: 2023-01-01T10:15:30 / 2023-01-01 10:15:30 + * - Seconds optional: 2023-01-01T10:15 + * - Fractional seconds with '.' or ',': 10:15:30.123 / 10:15:30,123 + * - Offset with colon: +02:00 + * - Offset without colon: +0200 + * - Zulu marker: Z + * - Trailing zone-id in brackets (stripped, not interpreted): +01:00[Europe/Berlin] + * + * Notes / deliberate limitations: + * - The target type is LocalDateTime, so any offset/zone information found in the + * input is parsed only to avoid failures - it is NOT applied to shift the time. + * If you need offset-aware conversion, parse to OffsetDateTime/ZonedDateTime instead. + * - The bracketed zone-id (e.g. "[Europe/Berlin]") is stripped via regex before + * parsing because DateTimeFormatterBuilder has no simple "consume and ignore" + * construct for that syntax. + * - Full ISO-8601 "basic" format (no separators at all, e.g. 20230101T101530Z) + * is intentionally NOT supported here to keep the formatter list manageable. + * Add another formatter to the list below if you need it. + */ public class LocalDateTimeISO8601XmlAdapter extends XmlAdapter { - private static final DateTimeFormatter formatter = new DateTimeFormatterBuilder().appendPattern("yyyy-MM-dd'T'HH:mm:ss") - .optionalStart().appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true).optionalEnd() - .optionalStart().appendPattern("XXXXX") - .optionalEnd() - -// - .parseDefaulting(ChronoField.OFFSET_SECONDS,OffsetDateTime.now().getLong(ChronoField.OFFSET_SECONDS) ).toFormatter(); - - @Override - public LocalDateTime unmarshal(String inputDate) { - return LocalDateTime.parse(inputDate, formatter); - - } - - @Override - public String marshal(LocalDateTime inputDate) { - if(inputDate != null) { - return formatter.format(inputDate); - } else { - return null; - } - } - -} + // Strips a trailing IANA zone id in brackets, e.g. "+01:00[Europe/Berlin]" -> "+01:00" + private static final Pattern ZONE_ID_SUFFIX = Pattern.compile("\\[.*\\]$"); + + // DecimalStyle that accepts ',' as fraction separator (used by the comma-variant formatter) + private static final DecimalStyle COMMA_DECIMAL_STYLE = DecimalStyle.STANDARD.withDecimalSeparator(','); + + /** + * Builds one formatter variant. + * + * @param dateTimeSeparatorLiteral the literal between date and time, e.g. "T" or " " + * @param decimalStyle decimal style to use for fractional seconds (dot or comma) + */ + private static DateTimeFormatter buildFormatter(String dateTimeSeparatorLiteral, DecimalStyle decimalStyle) { + DateTimeFormatterBuilder builder = new DateTimeFormatterBuilder() + // Date part: yyyy-MM-dd + .appendPattern("yyyy-MM-dd") + + // Time part is entirely optional -> supports date-only input + .optionalStart() + .appendLiteral(dateTimeSeparatorLiteral) + .appendPattern("HH:mm") + // Seconds are optional + .optionalStart() + .appendPattern(":ss") + .optionalEnd() + // Fractional seconds are optional (uses the decimalStyle set below) + .optionalStart() + .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true) + .optionalEnd() + // Offset with colon, e.g. +02:00, or 'Z' + .optionalStart() + .appendPattern("XXXXX") + .optionalEnd() + // Offset without colon, e.g. +0200 (only tried if the above didn't match) + .optionalStart() + .appendPattern("XX") + .optionalEnd() + .optionalEnd() + + // Default missing fields so that LocalTime.MIDNIGHT / 0 seconds are assumed + .parseDefaulting(ChronoField.HOUR_OF_DAY, 0) + .parseDefaulting(ChronoField.MINUTE_OF_HOUR, 0) + .parseDefaulting(ChronoField.SECOND_OF_MINUTE, 0) + // Fixed default offset (UTC) just so parsing of an "X" pattern never fails + // due to a missing offset. This value is irrelevant for the LocalDateTime + // result since offset info is discarded for this target type. + .parseDefaulting(ChronoField.OFFSET_SECONDS, 0); + + return builder.toFormatter().withDecimalStyle(decimalStyle); + } + + // Ordered list of formatter variants tried during unmarshalling. + // Order matters only for performance (most common case first); all variants + // are tried until one succeeds. + private static final List PARSERS = List.of( + buildFormatter("T", DecimalStyle.STANDARD), // 2023-01-01T10:15:30.123+02:00 + buildFormatter(" ", DecimalStyle.STANDARD), // 2023-01-01 10:15:30.123+02:00 + buildFormatter("T", COMMA_DECIMAL_STYLE), // 2023-01-01T10:15:30,123+02:00 + buildFormatter(" ", COMMA_DECIMAL_STYLE) // 2023-01-01 10:15:30,123+02:00 + ); + + // Formatter used for marshalling (output). Always produces a canonical, + // unambiguous representation: 'T' separator, dot as decimal separator. + private static final DateTimeFormatter OUTPUT_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS"); + + @Override + public LocalDateTime unmarshal(String inputDate) { + if (inputDate == null) { + return null; + } + + // Strip a trailing bracketed zone id, e.g. "...+01:00[Europe/Berlin]" -> "...+01:00" + String cleaned = ZONE_ID_SUFFIX.matcher(inputDate.trim()).replaceAll(""); + + DateTimeParseException lastException = null; + for (DateTimeFormatter formatter : PARSERS) { + try { + return LocalDateTime.parse(cleaned, formatter); + } catch (DateTimeParseException e) { + lastException = e; + // try next formatter variant + } + } + + // None of the variants matched - rethrow the last exception for diagnostics + throw lastException; + } + + @Override + public String marshal(LocalDateTime inputDate) { + if (inputDate == null) { + return null; + } + return OUTPUT_FORMATTER.format(inputDate); + } +} \ No newline at end of file diff --git a/src/test/java/org/rutebanken/netex/model/MarshalUnmarshalTest.java b/src/test/java/org/rutebanken/netex/model/MarshalUnmarshalTest.java index 21413c0c..0c6f0250 100644 --- a/src/test/java/org/rutebanken/netex/model/MarshalUnmarshalTest.java +++ b/src/test/java/org/rutebanken/netex/model/MarshalUnmarshalTest.java @@ -17,6 +17,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.rutebanken.util.LocalDateTimeISO8601XmlAdapter; import jakarta.xml.bind.JAXBContext; import jakarta.xml.bind.JAXBElement; @@ -38,17 +39,19 @@ import java.time.Duration; import java.time.LocalDateTime; import java.time.LocalTime; +import java.time.format.DateTimeParseException; import java.time.temporal.ChronoField; import java.util.Arrays; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertFalse; class MarshalUnmarshalTest { private static JAXBContext jaxbContext; - + private static final ObjectFactory factory = new ObjectFactory(); @BeforeAll @@ -62,7 +65,8 @@ void publicationDeliveryWithOffsetDateTime() throws JAXBException { Marshaller marshaller = jaxbContext.createMarshaller(); PublicationDeliveryStructure publicationDelivery = new PublicationDeliveryStructure() - .withDescription(new MultilingualString().withValue("value").withLang("no").withTextIdType("")).withPublicationTimestamp(LocalDateTime.now().withNano(0)) + .withDescription(new MultilingualString().withValue("value").withLang("no").withTextIdType("")) + .withPublicationTimestamp(LocalDateTime.now().withNano(0)) .withParticipantRef("participantRef"); marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true); @@ -141,7 +145,8 @@ void timetableWithVehicleModes() throws JAXBException { Marshaller marshaller = jaxbContext.createMarshaller(); TimetableFrame timetableFrame = factory.createTimetableFrame().withVersion("any").withId("TimetableFrame") - .withName(factory.createMultilingualString().withValue("TimetableFrame")).withVehicleModes(VehicleModeEnumeration.AIR); + .withName(factory.createMultilingualString().withValue("TimetableFrame")) + .withVehicleModes(VehicleModeEnumeration.AIR); marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true); ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); @@ -153,7 +158,8 @@ void timetableWithVehicleModes() throws JAXBException { Unmarshaller unmarshaller = jaxbContext.createUnmarshaller(); @SuppressWarnings("unchecked") - JAXBElement jaxbElement = (JAXBElement) unmarshaller.unmarshal(new ByteArrayInputStream(xml.getBytes())); + JAXBElement jaxbElement = (JAXBElement) unmarshaller + .unmarshal(new ByteArrayInputStream(xml.getBytes())); TimetableFrame actual = jaxbElement.getValue(); assertThat(actual.getVersion()).isNotNull().isNotEmpty().isEqualTo(timetableFrame.getVersion()); @@ -167,13 +173,17 @@ void timetableWithVehicleModes() throws JAXBException { void dayTypeWithPropertiesOfDay() throws JAXBException { Marshaller marshaller = jaxbContext.createMarshaller(); - List daysOfWeek = Arrays.asList(DayOfWeekEnumeration.MONDAY, DayOfWeekEnumeration.TUESDAY, DayOfWeekEnumeration.WEDNESDAY, + List daysOfWeek = Arrays.asList(DayOfWeekEnumeration.MONDAY, DayOfWeekEnumeration.TUESDAY, + DayOfWeekEnumeration.WEDNESDAY, DayOfWeekEnumeration.THURSDAY, DayOfWeekEnumeration.FRIDAY); - PropertyOfDay propertyOfDay = factory.createPropertyOfDay().withDescription(factory.createMultilingualString().withValue("PropertyOfDay")) + PropertyOfDay propertyOfDay = factory.createPropertyOfDay() + .withDescription(factory.createMultilingualString().withValue("PropertyOfDay")) .withName(factory.createMultilingualString().withValue("PropertyOfDay")).withDaysOfWeek(daysOfWeek); - PropertiesOfDay_RelStructure propertiesOfDay = factory.createPropertiesOfDay_RelStructure().withPropertyOfDay(propertyOfDay); + PropertiesOfDay_RelStructure propertiesOfDay = factory.createPropertiesOfDay_RelStructure() + .withPropertyOfDay(propertyOfDay); DayType dayType = factory.createDayType().withVersion("any").withId(String.format("%s:dt:weekday", "SK4488")) - .withName(factory.createMultilingualString().withValue("Ukedager (mandag til fredag)")).withProperties(propertiesOfDay); + .withName(factory.createMultilingualString().withValue("Ukedager (mandag til fredag)")) + .withProperties(propertiesOfDay); marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true); ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); @@ -185,7 +195,8 @@ void dayTypeWithPropertiesOfDay() throws JAXBException { Unmarshaller unmarshaller = jaxbContext.createUnmarshaller(); @SuppressWarnings("unchecked") - JAXBElement jaxbElement = (JAXBElement) unmarshaller.unmarshal(new ByteArrayInputStream(xml.getBytes())); + JAXBElement jaxbElement = (JAXBElement) unmarshaller + .unmarshal(new ByteArrayInputStream(xml.getBytes())); DayType actual = jaxbElement.getValue(); assertThat(actual.getVersion()).isNotNull().isNotEmpty().isEqualTo(dayType.getVersion()); @@ -211,20 +222,19 @@ void datedCallWithLocalDate() throws JAXBException { Unmarshaller unmarshaller = jaxbContext.createUnmarshaller(); - JAXBElement actual = (JAXBElement) unmarshaller.unmarshal(new ByteArrayInputStream(xml.getBytes())); + JAXBElement actual = (JAXBElement) unmarshaller + .unmarshal(new ByteArrayInputStream(xml.getBytes())); assertThat(actual.getValue().getArrivalDate()).isEqualTo(datedCall.getArrivalDate()); } - @Test void marshalledNamespacePrefixes() throws JAXBException { Marshaller marshaller = jaxbContext.createMarshaller(); PublicationDeliveryStructure publicationDeliveryStructure = new PublicationDeliveryStructure(); - marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true); ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); @@ -234,7 +244,8 @@ void marshalledNamespacePrefixes() throws JAXBException { System.out.println(xml); assertThat(xml) - .as("Namespace declaration without prefix for netex").contains("xmlns=\"http://www.netex.org.uk/netex\"") + .as("Namespace declaration without prefix for netex") + .contains("xmlns=\"http://www.netex.org.uk/netex\"") .as(" actual = (JAXBElement) unmarshaller.unmarshal(new ByteArrayInputStream(xml.getBytes())); + JAXBElement actual = (JAXBElement) unmarshaller + .unmarshal(new ByteArrayInputStream(xml.getBytes())); assertThat(actual.getValue().getChanged().getHour()).isEqualTo(datedCall.getChanged().getHour()); assertThat(actual.getValue().getChanged()).isEqualToIgnoringNanos(datedCall.getChanged()); @@ -271,28 +283,34 @@ void unmarshalPublicationDeliveryAndVerifyValidBetween() throws JAXBException, I CompositeFrame compositeFrame = (CompositeFrame) actual.dataObjects.compositeFrameOrCommonFrame .get(0).getValue(); ValidityConditions_RelStructure validityConditions = compositeFrame.getValidityConditions(); - ValidBetween validBetweenWithTimezone = (ValidBetween) validityConditions.getValidityConditionRefOrValidBetweenOrValidityCondition_().get(0); + ValidBetween validBetweenWithTimezone = (ValidBetween) validityConditions + .getValidityConditionRefOrValidBetweenOrValidityCondition_().get(0); assertThat(validBetweenWithTimezone.getFromDate()).isNotNull(); assertThat(validBetweenWithTimezone.getToDate()).isNotNull(); assertThat(validBetweenWithTimezone.getToDate()).hasToString("2017-01-01T11:00"); - ValidBetween validBetweenWithoutTimezone = (ValidBetween) validityConditions.getValidityConditionRefOrValidBetweenOrValidityCondition_().get(1); + ValidBetween validBetweenWithoutTimezone = (ValidBetween) validityConditions + .getValidityConditionRefOrValidBetweenOrValidityCondition_().get(1); assertThat(validBetweenWithoutTimezone.getFromDate()).isNotNull(); assertThat(validBetweenWithoutTimezone.getToDate()).isNotNull(); assertThat(validBetweenWithoutTimezone.getToDate()).hasToString("2017-01-01T12:00"); - Timetable_VersionFrameStructure timetableFrame = (Timetable_VersionFrameStructure) compositeFrame.getFrames().getCommonFrame().get(1).getValue(); - ServiceJourney_VersionStructure serviceJourney = (ServiceJourney_VersionStructure) timetableFrame.getVehicleJourneys() + Timetable_VersionFrameStructure timetableFrame = (Timetable_VersionFrameStructure) compositeFrame.getFrames() + .getCommonFrame().get(1).getValue(); + ServiceJourney_VersionStructure serviceJourney = (ServiceJourney_VersionStructure) timetableFrame + .getVehicleJourneys() .getVehicleJourneyOrDatedVehicleJourneyOrNormalDatedVehicleJourney().get(0); assertThat(serviceJourney.getDepartureTime()).isNotNull(); // Specified as local time assertThat(serviceJourney.getDepartureTime()).hasToString("07:55"); - LocalTime departureTimeZulu = serviceJourney.getPassingTimes().getTimetabledPassingTime().get(0).getDepartureTime(); + LocalTime departureTimeZulu = serviceJourney.getPassingTimes().getTimetabledPassingTime().get(0) + .getDepartureTime(); assertThat(departureTimeZulu).isNotNull().hasToString("07:55"); - LocalTime departureTimeOffset = serviceJourney.getPassingTimes().getTimetabledPassingTime().get(1).getArrivalTime(); + LocalTime departureTimeOffset = serviceJourney.getPassingTimes().getTimetabledPassingTime().get(1) + .getArrivalTime(); assertThat(departureTimeOffset).isNotNull().hasToString("08:40"); } @@ -332,7 +350,8 @@ void networkWithAuthorityRefRoundTrip() throws JAXBException { .unmarshal(new ByteArrayInputStream(xml.getBytes())); PublicationDeliveryStructure actual = jaxbElement.getValue(); - CompositeFrame compositeFrame = (CompositeFrame) actual.getDataObjects().getCompositeFrameOrCommonFrame().get(0).getValue(); + CompositeFrame compositeFrame = (CompositeFrame) actual.getDataObjects().getCompositeFrameOrCommonFrame().get(0) + .getValue(); ServiceFrame serviceFrame = (ServiceFrame) compositeFrame.getFrames().getCommonFrame().get(0).getValue(); Network actualNetwork = serviceFrame.getNetwork(); @@ -352,7 +371,8 @@ void flexibleLineRoundTrip() throws JAXBException { .withBookingAccess(BookingAccessEnumeration.PUBLIC) .withBookWhen(PurchaseWhenEnumeration.DAY_OF_TRAVEL_ONLY) .withLatestBookingTime(LocalTime.of(14, 0)) - .withBookingContact(new ContactStructure().withPhone("+47 11223344").withUrl("https://flex.example.com")); + .withBookingContact( + new ContactStructure().withPhone("+47 11223344").withUrl("https://flex.example.com")); marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true); ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); @@ -411,7 +431,8 @@ void blockRoundTrip() throws JAXBException { @Test void fragmentShouldNotContainNetexNamespace() throws Exception { - JAXBContext netexJaxBContext = JAXBContext.newInstance("net.opengis.gml._3:org.rutebanken.netex.model:uk.org.siri.siri"); + JAXBContext netexJaxBContext = JAXBContext + .newInstance("net.opengis.gml._3:org.rutebanken.netex.model:uk.org.siri.siri"); Marshaller marshaller = netexJaxBContext.createMarshaller(); marshaller.setProperty(jakarta.xml.bind.Marshaller.JAXB_ENCODING, StandardCharsets.UTF_8.name()); @@ -419,10 +440,10 @@ void fragmentShouldNotContainNetexNamespace() throws Exception { marshaller.setProperty(Marshaller.JAXB_FRAGMENT, Boolean.TRUE); StringWriter stringWriter = new StringWriter(); - AvailabilityCondition availabilityCondition = new AvailabilityCondition().withFromDate(LocalDateTime.now()).withToDate(LocalDateTime.now()).withId("NSR:AvailabilityCondition:2").withVersion("v1"); - + AvailabilityCondition availabilityCondition = new AvailabilityCondition().withFromDate(LocalDateTime.now()) + .withToDate(LocalDateTime.now()).withId("NSR:AvailabilityCondition:2").withVersion("v1"); - String netexNamespace="http://www.netex.org.uk/netex"; + String netexNamespace = "http://www.netex.org.uk/netex"; XMLOutputFactory outputFactory = XMLOutputFactory.newFactory(); XMLStreamWriter xmlStreamWriter = outputFactory.createXMLStreamWriter(stringWriter); @@ -434,7 +455,7 @@ void fragmentShouldNotContainNetexNamespace() throws Exception { assertFalse(xml.contains(netexNamespace)); } - @Test + @Test void unmarshalPublicationDeliveryAndVerifyDateTimeNanoSeconds() throws JAXBException { String xml = "" @@ -484,4 +505,163 @@ void unmarshalPublicationDeliveryAndVerifyDateTimeNanoSeconds() throws JAXBExcep assertThat(actual.getPublicationTimestamp().getHour()).isEqualTo(15); } + @Test + void unmarshalPublicationDeliveryWithoutSeconds() throws JAXBException { + // Case 1: time without seconds, e.g. "15:00+01:00" + String xml = "" + + "" + + " 2016-05-18T15:10+01:00" + + " NHR" + + " " + + ""; + + Unmarshaller unmarshaller = jaxbContext.createUnmarshaller(); + + @SuppressWarnings("unchecked") + JAXBElement jaxbElement = (JAXBElement) unmarshaller + .unmarshal(new ByteArrayInputStream(xml.getBytes())); + PublicationDeliveryStructure actual = jaxbElement.getValue(); + + assertThat(actual.getPublicationTimestamp().getHour()).isEqualTo(15); + assertThat(actual.getPublicationTimestamp().getMinute()).isEqualTo(10); + assertThat(actual.getPublicationTimestamp().getSecond()).isEqualTo(0); + } + + @Test + void unmarshalPublicationDeliveryWithSpaceSeparator() throws JAXBException { + // Case 3: space instead of 'T' as date/time separator + String xml = "" + + "" + + " 2016-05-18 15:00:00+01:00" + + " NHR" + + " " + + ""; + + Unmarshaller unmarshaller = jaxbContext.createUnmarshaller(); + + @SuppressWarnings("unchecked") + JAXBElement jaxbElement = (JAXBElement) unmarshaller + .unmarshal(new ByteArrayInputStream(xml.getBytes())); + PublicationDeliveryStructure actual = jaxbElement.getValue(); + + assertThat(actual.getPublicationTimestamp().getHour()).isEqualTo(15); + } + + @Test + void unmarshalPublicationDeliveryWithOffsetWithoutColon() throws JAXBException { + // Case 4: offset without colon, e.g. "+0100" instead of "+01:00" + String xml = "" + + "" + + " 2016-05-18T15:00:00+0100" + + " NHR" + + " " + + ""; + + Unmarshaller unmarshaller = jaxbContext.createUnmarshaller(); + + @SuppressWarnings("unchecked") + JAXBElement jaxbElement = (JAXBElement) unmarshaller + .unmarshal(new ByteArrayInputStream(xml.getBytes())); + PublicationDeliveryStructure actual = jaxbElement.getValue(); + + assertThat(actual.getPublicationTimestamp().getHour()).isEqualTo(15); + } + + @Test + void unmarshalPublicationDeliveryWithCommaAsFractionSeparator() throws JAXBException { + // Case 7: comma instead of dot as decimal separator for fractional seconds + String xml = "" + + "" + + " 2016-05-18T15:00:00,123+01:00" + + " NHR" + + " " + + ""; + + Unmarshaller unmarshaller = jaxbContext.createUnmarshaller(); + + @SuppressWarnings("unchecked") + JAXBElement jaxbElement = (JAXBElement) unmarshaller + .unmarshal(new ByteArrayInputStream(xml.getBytes())); + PublicationDeliveryStructure actual = jaxbElement.getValue(); + + assertThat(actual.getPublicationTimestamp().getHour()).isEqualTo(15); + assertThat(actual.getPublicationTimestamp().getNano()).isEqualTo(123_000_000); + } + + @Test + void unmarshalPublicationDeliveryWithBracketedZoneId() throws JAXBException { + // Case 8: trailing IANA zone id in brackets, e.g. "+01:00[Europe/Berlin]" + // (as produced by ZonedDateTime.toString()). The zone id itself is stripped + // and not interpreted; only the offset and local fields are used. + String xml = "" + + "" + + " 2016-05-18T15:00:00+01:00[Europe/Berlin]" + + " NHR" + + " " + + ""; + + Unmarshaller unmarshaller = jaxbContext.createUnmarshaller(); + + @SuppressWarnings("unchecked") + JAXBElement jaxbElement = (JAXBElement) unmarshaller + .unmarshal(new ByteArrayInputStream(xml.getBytes())); + PublicationDeliveryStructure actual = jaxbElement.getValue(); + + assertThat(actual.getPublicationTimestamp().getHour()).isEqualTo(15); + } + + @Test + void unmarshalDateOnlyDefaultsToMidnight() { + // Case: date-only input (no time component at all) should default to midnight. + LocalDateTimeISO8601XmlAdapter adapter = new LocalDateTimeISO8601XmlAdapter(); + + LocalDateTime result = adapter.unmarshal("2016-05-18"); + + assertThat(result.getYear()).isEqualTo(2016); + assertThat(result.getMonthValue()).isEqualTo(5); + assertThat(result.getDayOfMonth()).isEqualTo(18); + assertThat(result.getHour()).isEqualTo(0); + assertThat(result.getMinute()).isEqualTo(0); + assertThat(result.getSecond()).isEqualTo(0); + } + + @Test + void unmarshalRejectsBasicIso8601FormatWithoutSeparators() { + // Case 5 (documented limitation): the compact "basic" ISO-8601 format + // without any separators (e.g. "20160518T150000Z") is intentionally NOT + // supported by the current adapter. This test documents that behaviour + // so a future change is a conscious decision rather than an accident. + LocalDateTimeISO8601XmlAdapter adapter = new LocalDateTimeISO8601XmlAdapter(); + + assertThatThrownBy(() -> adapter.unmarshal("20160518T150000Z")) + .isInstanceOf(DateTimeParseException.class); + } + + @Test + void unmarshalDoesNotDependOnSystemDefaultOffsetAtRuntime() { + // Case 10: the parsed result must not depend on "now" (e.g. current DST + // offset) - it only matters that parsing succeeds and the local fields + // are correct, regardless of when the test is executed. + LocalDateTimeISO8601XmlAdapter adapter = new LocalDateTimeISO8601XmlAdapter(); + + // Winter date (CET, +01:00) and summer date (CEST, +02:00) - both must + // parse correctly and independently of the JVM's current date/offset. + LocalDateTime winter = adapter.unmarshal("2016-01-18T15:00:00+01:00"); + LocalDateTime summer = adapter.unmarshal("2016-07-18T15:00:00+02:00"); + + assertThat(winter.getHour()).isEqualTo(15); + assertThat(summer.getHour()).isEqualTo(15); + } + + @Test + void marshalProducesCanonicalFormat() { + // Round-trip sanity check: marshalling always produces a single, + // unambiguous representation regardless of which input variant was parsed. + LocalDateTimeISO8601XmlAdapter adapter = new LocalDateTimeISO8601XmlAdapter(); + + LocalDateTime parsed = adapter.unmarshal("2016-05-18 15:00:00,500+01:00"); + String marshalled = adapter.marshal(parsed); + + assertThat(marshalled).isEqualTo("2016-05-18T15:00:00.500"); + } }