Merge pull request #696 from jamezp/issue695-date-format

[695] Fix date deserialization for date-only patterns
diff --git a/src/main/java/org/eclipse/yasson/internal/deserializer/types/DateDeserializer.java b/src/main/java/org/eclipse/yasson/internal/deserializer/types/DateDeserializer.java
index 723cdbe..4e3704a 100644
--- a/src/main/java/org/eclipse/yasson/internal/deserializer/types/DateDeserializer.java
+++ b/src/main/java/org/eclipse/yasson/internal/deserializer/types/DateDeserializer.java
@@ -13,13 +13,30 @@
 package org.eclipse.yasson.internal.deserializer.types;
 
 import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.YearMonth;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
 import java.time.ZonedDateTime;
 import java.time.format.DateTimeFormatter;
+import java.time.temporal.TemporalAccessor;
 import java.util.Date;
 import java.util.Locale;
 
 /**
  * Deserializer of the {@link Date} type.
+ *
+ * <p>
+ * For date-only patterns (e.g., "yyyy-MM-dd"), this deserializer uses {@link DateTimeFormatter#parseBest} to detect the
+ * appropriate temporal type [ZonedDateTime, LocalDateTime, LocalDate, or YearMonth]
+ * and creates the Date object at midnight in the specified
+ * timezone. When no timezone is specified in the pattern, UTC is used as required by Jakarta JSON Binding specification
+ * section 3.5.
+ * </p>
+ * <p>
+ * critical, use {@link java.time.LocalDate} (recommended) or {@link java.sql.Date}.
+ * </p>
  */
 class DateDeserializer extends AbstractDateDeserializer<Date> {
 
@@ -45,13 +62,31 @@
     }
 
     private static Date parseWithOrWithoutZone(String jsonValue, DateTimeFormatter formatter) {
-        ZonedDateTime parsed;
-        if (formatter.getZone() == null) {
-            parsed = ZonedDateTime.parse(jsonValue, formatter.withZone(UTC));
+        final TemporalAccessor best = formatter.parseBest(jsonValue,
+                ZonedDateTime::from,
+                LocalDateTime::from,
+                LocalDate::from,
+                YearMonth::from);
+
+        // If no zone provided in string, use the formatter's zone or UTC per the Jakarta JSON Binding specification
+        // section 3.5
+        final ZoneId zone = formatter.getZone() != null ? formatter.getZone() : ZoneOffset.UTC;
+
+        // Determine the type of the best option
+        final Instant instant;
+        if (best instanceof ZonedDateTime) {
+            instant = ((ZonedDateTime) best).toInstant();
+        } else if (best instanceof LocalDateTime) {
+            instant = ((LocalDateTime) best).atZone(zone).toInstant();
+        } else if (best instanceof LocalDate) {
+            instant = LocalDate.from(best).atStartOfDay(zone).toInstant();
+        } else if (best instanceof YearMonth) {
+            instant = ((YearMonth) best).atDay(1).atStartOfDay(zone).toInstant();
         } else {
-            parsed = ZonedDateTime.parse(jsonValue, formatter);
+            // Fallback
+            instant = Instant.from(best);
         }
-        return Date.from(parsed.toInstant());
+        return Date.from(instant);
     }
 
 }
diff --git a/src/test/java/org/eclipse/yasson/defaultmapping/dates/DatesTest.java b/src/test/java/org/eclipse/yasson/defaultmapping/dates/DatesTest.java
index 287afca..79ad21e 100644
--- a/src/test/java/org/eclipse/yasson/defaultmapping/dates/DatesTest.java
+++ b/src/test/java/org/eclipse/yasson/defaultmapping/dates/DatesTest.java
@@ -102,6 +102,10 @@
     public static class SqlDateFormatted {
         @JsonbDateFormat(value = "yyyy-MM-dd")
         public java.sql.Date sqlDate;
+        @JsonbDateFormat(value = "yyyy-MM-dd")
+        public java.util.Date utilDate;
+
+
     }
 
     @Test
@@ -126,7 +130,101 @@
         assertEquals("2018-01-31", result.sqlDate.toString());
         assertEquals("2018-01-31", result.utilDate.toString());
     }
-    
+
+    @Test
+    public void testMarshallSqlDateFormatted() {
+        final String date = "2026-02-25";
+        final String expectedJson = String.format("{\"sqlDate\":\"%1$s\",\"utilDate\":\"%1$s\"}", date);
+
+        final SqlDateFormatted sqlDateFormatted = new SqlDateFormatted();
+        sqlDateFormatted.sqlDate = java.sql.Date.valueOf(date);
+        // We use a java.sql.Date here as we want to test as if this was a Jakarta Persistence temporal date
+        sqlDateFormatted.utilDate = java.sql.Date.valueOf(date);
+        String jsonString = bindingJsonb.toJson(sqlDateFormatted);
+        assertEquals(expectedJson, jsonString);
+
+        // Unmarshal the object
+        final SqlDateFormatted result = bindingJsonb.fromJson(jsonString, SqlDateFormatted.class);
+        assertEquals(sqlDateFormatted.sqlDate, result.sqlDate);
+        // The Date objects will not be equal unless user.timezone is set to UTC. The sqlDateFormatted.utilDate is
+        // created at midnight in the current timezone (via valueOf()), while result.utilDate is created at midnight UTC
+        // per the JSON-B specification. To verify both represent the same calendar date, we convert each to LocalDate
+        // using its respective timezone: the original uses systemDefault(), the deserialized uses UTC.
+        assertEquals(Instant.ofEpochMilli(sqlDateFormatted.utilDate.getTime()).atZone(ZoneId.systemDefault()).toLocalDate(),
+                result.utilDate.toInstant().atZone(ZoneOffset.UTC).toLocalDate());
+    }
+
+    @Test
+    public void testUnmarshallSqlDateFormatted() {
+        final String date = "2026-02-25";
+        final String expectedString = String.format("{\"sqlDate\":\"%1$s\",\"utilDate\":\"%1$s\"}", date);
+
+        final SqlDateFormatted sqlDateFormatted = bindingJsonb.fromJson(expectedString, SqlDateFormatted.class);
+        assertEquals(date, sqlDateFormatted.sqlDate.toString());
+        // Convert java.util.Date to LocalDate for comparison
+        final LocalDate resultDate = sqlDateFormatted.utilDate.toInstant()
+                .atZone(ZoneOffset.UTC)
+                .toLocalDate();
+        assertEquals(LocalDate.parse(date),  resultDate);
+
+        // Unmarshal the object
+        final String result = bindingJsonb.toJson(sqlDateFormatted);
+        assertEquals(expectedString, result);
+    }
+
+    public static class YearMonthFormatted {
+        @JsonbDateFormat(value = "yyyy-MM")
+        public java.util.Date date;
+    }
+
+    @Test
+    public void testMarshallYearMonthFormat() {
+        final YearMonthFormatted yearMonthFormatted = new YearMonthFormatted();
+        yearMonthFormatted.date = java.sql.Date.valueOf("2026-02-25");
+        String jsonString = bindingJsonb.toJson(yearMonthFormatted);
+        assertEquals("{\"date\":\"2026-02\"}", jsonString);
+    }
+
+    @Test
+    public void testUnmarshallYearMonthFormat() {
+        final YearMonthFormatted yearMonthFormatted = bindingJsonb.fromJson(
+                "{\"date\":\"2026-02\"}",
+                YearMonthFormatted.class);
+        final LocalDate resultDate = yearMonthFormatted.date.toInstant()
+                .atZone(ZoneOffset.UTC)
+                .toLocalDate();
+        assertEquals(LocalDate.of(2026, 2, 1), resultDate);
+    }
+
+    @Test
+    public void testDateOnlyPatternEdgeCases() {
+        // Test various edge cases to ensure date values are preserved correctly
+        testDateRoundTrip("2028-03-01"); // Day after leap year
+        testDateRoundTrip("2026-12-31"); // Last day of year
+        testDateRoundTrip("2028-02-29"); // Leap year day
+        testDateRoundTrip("2027-01-01"); // First day of year
+        testDateRoundTrip("2028-01-31"); // Last day of January
+        testDateRoundTrip("2028-02-01"); // First day of February
+    }
+
+    private void testDateRoundTrip(final String date) {
+        final String json = String.format("{\"sqlDate\":\"%1$s\",\"utilDate\":\"%1$s\"}", date);
+
+        // Deserialize
+        final SqlDateFormatted deserialized = bindingJsonb.fromJson(json, SqlDateFormatted.class);
+
+        // Verify utilDate represents midnight UTC for the specified date
+        final LocalDate resultDate = deserialized.utilDate.toInstant()
+                .atZone(ZoneOffset.UTC)
+                .toLocalDate();
+        assertEquals(LocalDate.parse(date), resultDate, () -> String.format("Date should be %s when viewed in UTC", date));
+
+        // Verify JSON round-trip
+        final String roundTripped = bindingJsonb.toJson(deserialized);
+        assertEquals(json, roundTripped, () -> String.format("JSON should round-trip correctly for %s", date));
+    }
+
+
     @Test
     public void testSqlDateTimeZonesFormatted() {
         testSqlDateWithTZFormatted(TimeZone.getTimeZone(ZoneId.of("Europe/Sofia")));