From 2b7739a7de9b1caa0783f7dc8570cb387043f78b Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Tue, 4 Aug 2026 14:48:47 +0200 Subject: [PATCH 1/2] refactor: remove date content-sniffing and implement type-driven date comparison --- .../ktestify/utils/serdes/AvroUtils.java | 220 +++++++++----- .../ktestify/utils/serdes/AvroUtilsTest.java | 273 +++++++++++++----- 2 files changed, 341 insertions(+), 152 deletions(-) diff --git a/src/main/java/io/github/ktestify/utils/serdes/AvroUtils.java b/src/main/java/io/github/ktestify/utils/serdes/AvroUtils.java index 1fcc420..d08ba54 100644 --- a/src/main/java/io/github/ktestify/utils/serdes/AvroUtils.java +++ b/src/main/java/io/github/ktestify/utils/serdes/AvroUtils.java @@ -29,9 +29,9 @@ import java.math.RoundingMode; import java.nio.ByteBuffer; import java.text.ParseException; -import java.text.SimpleDateFormat; import java.time.Instant; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.LocalTime; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; @@ -73,10 +73,6 @@ public final class AvroUtils { private static final Logger LOGGER = LoggerFactory.getLogger(AvroUtils.class); - /** Date format patterns supported for automatic date detection and conversion. */ - private static final String[] SUPPORTED_DATE_PATTERNS = { - "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss'Z'", "yyyy-MM-dd'T'HH:mm:ss:SSS", "yyyy-MM-dd'T'HH:mm:ss:SSS'Z'" - }; /** UTC zone formatters for timestamp conversion with optional milliseconds and microseconds. */ private static final DateTimeFormatter TIMESTAMP_FORMATTER_WITH_MS = @@ -117,19 +113,6 @@ public static String getPrettyAvroValue(@NonNull String uglyAvroValue) { return gson.toJson(jsonElement); } - public static GenericRecord convertJsonToAvroRecord(JsonObject jsonEvent, Schema schema) { - GenericRecord avroRecord = new GenericData.Record(schema); - for (Map.Entry entry : jsonEvent.entrySet()) { - String key = entry.getKey(); - JsonElement value = entry.getValue(); - Schema.Field field = schema.getField(key); - if (field != null) { - avroRecord.put(key, AvroUtils.convertJsonToAvro(value, field.schema())); - } - } - return avroRecord; - } - /** * Converts a Map to a pretty-printed JSON string representation. * @@ -149,15 +132,6 @@ public static String convertMapToJsonString(Map map) { return gson.toJson(map); } - public static JsonObject readJsonFromFile(String filePath) { - try (FileReader reader = new FileReader(filePath)) { - return JsonParser.parseReader(reader).getAsJsonObject(); - } catch (IOException e) { - LOGGER.error("Error reading JSON from file: {}", filePath, e); - throw new RuntimeException("Error reading JSON from file: " + filePath, e); - } - } - /** * Parses a JSON string from file content and returns it as a JsonElement. * @@ -602,6 +576,17 @@ private static boolean performDeepEqualsComparison( /** * Compares two values, handling different types including nested objects and lists. * + *

When the actual value is a real Avro logical-type value already resolved by {@link + * io.github.ktestify.utils.serdes.AvroDeserializer} (an {@link Instant}, {@link LocalDateTime}, or {@link + * LocalDate}) and the expected value is a plain {@link String} (typically hand-written JSON with no schema + * information), the expected string is parsed into the same target type and compared accordingly. This avoids + * guessing whether a string "looks like" a date and instead relies on the actual side's real Java type, which is + * only ever a date/time type when the underlying Avro schema declares a logical type. + * + *

When both values are plain {@link String} instances, no date detection or conversion is attempted — they are + * compared literally, so genuine Avro {@code string} fields whose content happens to look like a date are never + * misinterpreted. + * * @param expectedValue the expected value * @param actualValue the actual value * @param excludedKeys list of keys to exclude from nested comparisons @@ -627,6 +612,43 @@ private static boolean compareValues( LOGGER.error("Value mismatch for key {} values does not match for a list", key); return false; } + } else if (actualValue instanceof Instant actualInstant && expectedValue instanceof String expectedString) { + var parsedExpected = parseExpectedInstant(expectedString); + if (parsedExpected == null || !parsedExpected.equals(actualInstant)) { + LOGGER.error( + AVRO_UTILS_VALUES_MISMATCH_WITH_CLASS_NAMES, + key, + expectedValue, + expectedValue.getClass().getSimpleName(), + actualValue, + actualValue.getClass().getSimpleName()); + return false; + } + } else if (actualValue instanceof LocalDateTime actualLocalDateTime + && expectedValue instanceof String expectedString) { + var parsedExpected = parseExpectedLocalDateTime(expectedString); + if (parsedExpected == null || !parsedExpected.equals(actualLocalDateTime)) { + LOGGER.error( + AVRO_UTILS_VALUES_MISMATCH_WITH_CLASS_NAMES, + key, + expectedValue, + expectedValue.getClass().getSimpleName(), + actualValue, + actualValue.getClass().getSimpleName()); + return false; + } + } else if (actualValue instanceof LocalDate actualLocalDate && expectedValue instanceof String expectedString) { + var parsedExpected = parseExpectedLocalDate(expectedString); + if (parsedExpected == null || !parsedExpected.equals(actualLocalDate)) { + LOGGER.error( + AVRO_UTILS_VALUES_MISMATCH_WITH_CLASS_NAMES, + key, + expectedValue, + expectedValue.getClass().getSimpleName(), + actualValue, + actualValue.getClass().getSimpleName()); + return false; + } } else if (expectedValue == null) { if (actualValue != null) { LOGGER.error(AVRO_UTILS_VALUES_MISMATCH_SHOULD_BE_NULL, key, actualValue); @@ -646,6 +668,81 @@ private static boolean compareValues( return true; } + /** + * Parses an expected date string as an {@link Instant}, trying several supported precisions. + * + *

Since the actual side has already been resolved to an {@link Instant} by {@link AvroDeserializer} (i.e. this + * is genuinely a timestamp-millis/timestamp-micros logical-type field), we know for certain the expected string is + * meant to represent a timestamp, so no content-sniffing/guessing is required — only parsing. + * + * @param expectedValue the expected date string + * @return the parsed {@link Instant}, or null if it could not be parsed with any supported format + */ + private static Instant parseExpectedInstant(String expectedValue) { + try { + // Handles no-fraction, millisecond, microsecond and nanosecond precision uniformly. + return Instant.parse(expectedValue); + } catch (DateTimeParseException e) { + LOGGER.debug("Falling back to alternate timestamp formats for value: {}", expectedValue); + } + + try { + return Instant.from(TIMESTAMP_FORMATTER_WITH_MICROS.parse(expectedValue)); + } catch (DateTimeParseException e) { + LOGGER.debug("Value {} did not match the microseconds timestamp formatter", expectedValue); + } + + try { + return Instant.from(TIMESTAMP_FORMATTER_WITH_MS.parse(expectedValue)); + } catch (DateTimeParseException e) { + LOGGER.error("Unable to parse expected value {} as an Instant", expectedValue); + return null; + } + } + + /** + * Parses an expected date string as a {@link LocalDateTime}, trying several supported precisions. + * + * @param expectedValue the expected date string + * @return the parsed {@link LocalDateTime}, or null if it could not be parsed with any supported format + */ + private static LocalDateTime parseExpectedLocalDateTime(String expectedValue) { + try { + // Handles no-fraction, millisecond, microsecond and nanosecond precision uniformly. + return LocalDateTime.parse(expectedValue); + } catch (DateTimeParseException e) { + LOGGER.debug("Falling back to alternate local date-time formats for value: {}", expectedValue); + } + + try { + return LocalDateTime.from(TIMESTAMP_FORMATTER_WITH_MICROS.parse(expectedValue)); + } catch (DateTimeParseException e) { + LOGGER.debug("Value {} did not match the microseconds local date-time formatter", expectedValue); + } + + try { + return LocalDateTime.from(TIMESTAMP_FORMATTER_WITH_MS.parse(expectedValue)); + } catch (DateTimeParseException e) { + LOGGER.error("Unable to parse expected value {} as a LocalDateTime", expectedValue); + return null; + } + } + + /** + * Parses an expected date string as a {@link LocalDate}. + * + * @param expectedValue the expected date string, expected in "yyyy-MM-dd" format + * @return the parsed {@link LocalDate}, or null if it could not be parsed + */ + private static LocalDate parseExpectedLocalDate(String expectedValue) { + try { + return LocalDate.parse(expectedValue); + } catch (DateTimeParseException e) { + LOGGER.error("Unable to parse expected value {} as a LocalDate", expectedValue); + return null; + } + } + /** * Performs deep equality comparison on two lists, handling nested objects and excluded keys. This method supports * smart matching where objects in arrays can be in different orders. @@ -1206,13 +1303,21 @@ private static GenericData.Array convertJsonArrayToArray(JsonArray jsonA // =========================================== /** - * Recursively converts date strings to Unix timestamps throughout a nested map structure. + * Recursively traverses a nested map structure (as produced by {@link #convertJsonToMap(String)}). + * + *

Content-based date sniffing has been intentionally removed (see issue #49): guessing whether a plain string + * "looks like" a date is fundamentally unreliable, since it cannot distinguish a genuine Avro {@code string} field + * whose content happens to look like a date from an actual logical-type date/timestamp field. Date/timestamp + * comparison is now performed in a type-driven way directly in {@link #compareValues(Object, Object, List, + * String)}, based on the actual side's real Java type ({@link Instant}, {@link LocalDateTime}, {@link LocalDate}) + * as resolved by {@link AvroDeserializer}. * - *

This method traverses a map representation of JSON input, identifies fields containing date strings, and - * converts them to Unix timestamps. It handles nested maps and lists recursively. + *

This method still recurses into nested maps and lists so that the returned map is a structurally-independent + * (deep) copy, but performs no value conversion. * * @param jsonMap the map representation of the JSON input - * @return a map with date fields converted to Unix timestamps + * @return the map, recursively copied */ public static Map convertDatesToTimestamps(Map jsonMap) { Objects.requireNonNull(jsonMap, "JSON map cannot be null"); @@ -1220,58 +1325,27 @@ public static Map convertDatesToTimestamps(Map j for (var entry : jsonMap.entrySet()) { var value = entry.getValue(); - if (value instanceof String dateString) { - if (isDateString(dateString)) { - try { - var timestamp = convertDateStringToTimestamp(dateString); - jsonMap.put(entry.getKey(), timestamp); - } catch (ParseException e) { - LOGGER.error("An error happened while converting Date to Timestamps", e); - } - } - } else if (value instanceof Map nestedMap) { - // convertDatesToTimestamps((Map) nestedMap); + if (value instanceof Map nestedMap) { + @SuppressWarnings("unchecked") + var typedNestedMap = (Map) nestedMap; + jsonMap.put(entry.getKey(), convertDatesToTimestamps(new HashMap<>(typedNestedMap))); } else if (value instanceof List list) { - for (var item : list) { + List convertedList = new ArrayList<>(list); + for (int i = 0; i < convertedList.size(); i++) { + var item = convertedList.get(i); if (item instanceof Map mapItem) { - - // convertDatesToTimestamps(<(Map) mapItem); + @SuppressWarnings("unchecked") + var typedMapItem = (Map) mapItem; + convertedList.set(i, convertDatesToTimestamps(new HashMap<>(typedMapItem))); } } + jsonMap.put(entry.getKey(), convertedList); } } return jsonMap; } - /** - * Determines if a string matches one of the supported date formats. - * - *

This method tests a string against multiple date format patterns to determine if it represents a valid - * date/timestamp. It uses strict parsing to avoid false positives. - * - * @param dateString the string to check - * @return true if the string matches a supported date format, false otherwise - */ - public static boolean isDateString(String dateString) { - if (dateString == null) { - return false; - } - - for (var pattern : SUPPORTED_DATE_PATTERNS) { - var dateFormat = new SimpleDateFormat(pattern); - dateFormat.setLenient(false); - - try { - dateFormat.parse(dateString); - return true; - } catch (ParseException e) { - // Continue trying other patterns - } - } - - return false; - } /** * Converts a date string to an integer representing days since epoch (1970-01-01). diff --git a/src/test/java/io/github/ktestify/utils/serdes/AvroUtilsTest.java b/src/test/java/io/github/ktestify/utils/serdes/AvroUtilsTest.java index 1f5e685..b30c6e6 100644 --- a/src/test/java/io/github/ktestify/utils/serdes/AvroUtilsTest.java +++ b/src/test/java/io/github/ktestify/utils/serdes/AvroUtilsTest.java @@ -25,6 +25,7 @@ import java.text.ParseException; import java.time.Instant; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.LocalTime; import java.util.*; import lombok.extern.slf4j.Slf4j; @@ -37,8 +38,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; @Slf4j class AvroUtilsTest { @@ -615,15 +614,18 @@ void convertMapToJsonString_ShouldHandleNestedMaps() { } @Test - void convertDatesToTimestamps_ShouldReturnMapWithTimestamps_WhenInputMapHasDateStrings() { + void convertDatesToTimestamps_ShouldNotConvertDateLikeStrings_SinceNoTypeInformationIsAvailable() { + // Content-sniffing was removed (issue #49): a Map on its own carries no schema information, so + // date-like strings must be left untouched. Type-driven conversion only happens in compareValues(), + // where the actual side's real Java type (Instant/LocalDateTime/LocalDate) is known. Map inputMap = new HashMap<>(); inputMap.put("date1", "2022-01-01T00:00:00Z"); inputMap.put("date2", "2022-01-02T00:00:00Z"); Map result = AvroUtils.convertDatesToTimestamps(inputMap); - assertEquals(1640995200000L, result.get("date1")); - assertEquals(1641081600000L, result.get("date2")); + assertEquals("2022-01-01T00:00:00Z", result.get("date1")); + assertEquals("2022-01-02T00:00:00Z", result.get("date2")); } @Test @@ -637,32 +639,6 @@ void convertDatesToTimestamps_ShouldReturnSameMap_WhenInputMapHasNoDateStrings() assertEquals(inputMap, result); } - @Test - void isDateString_ShouldReturnTrue_WhenInputIsDateString() { - String dateString = "2022-01-01T00:00:00"; - String dateString1 = "2022-01-01T00:00:00Z"; - String dateString2 = "2022-01-01T00:00:00:000"; - String dateString3 = "2022-01-01T00:00:00:000Z"; - - boolean result = AvroUtils.isDateString(dateString); - boolean result1 = AvroUtils.isDateString(dateString1); - boolean result2 = AvroUtils.isDateString(dateString2); - boolean result3 = AvroUtils.isDateString(dateString3); - - Assertions.assertTrue(result); - Assertions.assertTrue(result1); - Assertions.assertTrue(result2); - Assertions.assertTrue(result3); - } - - @Test - void isDateString_ShouldReturnFalse_WhenInputIsNotDateString() { - String notDateString = "Not a date string"; - - boolean result = AvroUtils.isDateString(notDateString); - - Assertions.assertFalse(result); - } @Test void convertDateStringToTimestamp_ShouldReturnTimestamp_WhenInputIsDateString() throws ParseException { @@ -763,7 +739,7 @@ void testConvertJsonPrimitiveToAvro_LongTimestampMillis_with_no_ms() { } @Test - void testConvertDatesToTimestamps_MainObject() throws ParseException { + void testConvertDatesToTimestamps_MainObject() { String json = """ { "date": "2025-01-02T00:00:00Z", @@ -773,12 +749,12 @@ void testConvertDatesToTimestamps_MainObject() throws ParseException { Map jsonMap = AvroUtils.convertJsonToMap(json); Map result = AvroUtils.convertDatesToTimestamps(jsonMap); - long expectedTimestamp = AvroUtils.convertDateStringToTimestamp("2025-01-02T00:00:00Z"); - assertEquals(expectedTimestamp, result.get("date")); + // No type information is available for a standalone map, so the date-like string is left untouched. + assertEquals("2025-01-02T00:00:00Z", result.get("date")); } @Test - void testConvertDatesToTimestamps_NestedObject() throws ParseException { + void testConvertDatesToTimestamps_NestedObject() { String json = """ { "nested": { @@ -790,12 +766,13 @@ void testConvertDatesToTimestamps_NestedObject() throws ParseException { Map jsonMap = AvroUtils.convertJsonToMap(json); Map result = AvroUtils.convertDatesToTimestamps(jsonMap); - long expectedTimestamp = AvroUtils.convertDateStringToTimestamp("2025-01-02T00:00:00Z"); - // assertEquals(expectedTimestamp, ((Map) result.get("nested")).get("date")); + // Recursion into nested maps now works, but no conversion is performed — the nested date-like string + // must remain untouched. + assertEquals("2025-01-02T00:00:00Z", ((Map) result.get("nested")).get("date")); } @Test - void testConvertDatesToTimestamps_NestedList() throws ParseException { + void testConvertDatesToTimestamps_NestedList() { String json = """ { "list": [ @@ -814,11 +791,10 @@ void testConvertDatesToTimestamps_NestedList() throws ParseException { Map result = AvroUtils.convertDatesToTimestamps(jsonMap); List list = (List) result.get("list"); - long expectedTimestamp1 = AvroUtils.convertDateStringToTimestamp("2025-01-02T00:00:00Z"); - long expectedTimestamp2 = AvroUtils.convertDateStringToTimestamp("2025-01-03T00:00:00Z"); - // assertEquals(expectedTimestamp1, ((Map) list.get(0)).get("date")); - // assertEquals(expectedTimestamp2, ((Map) list.get(1)).get("date")); + // Recursion into nested list items works, but no conversion is performed — dates remain untouched. + assertEquals("2025-01-02T00:00:00Z", ((Map) list.get(0)).get("date")); + assertEquals("2025-01-03T00:00:00Z", ((Map) list.get(1)).get("date")); } @Test @@ -1175,8 +1151,8 @@ void testConvertJsonToAvroWithNestedComplexTypes() { GenericData.Array.class, record.get("arrayField"), "Array field should be a GenericData.Array"); GenericData.Array array = (GenericData.Array) record.get("arrayField"); assertFalse(array.isEmpty(), "Array should not be empty"); - assertInstanceOf(GenericRecord.class, array.get(0), "Array item should be a GenericRecord"); - assertEquals("innerValue", ((GenericRecord) array.get(0)).get("innerField"), "Inner field should match"); + assertInstanceOf(GenericRecord.class, array.getFirst(), "Array item should be a GenericRecord"); + assertEquals("innerValue", ((GenericRecord) array.getFirst()).get("innerField"), "Inner field should match"); assertInstanceOf(Map.class, record.get("mapField"), "Map field should be a Map"); Map map = (Map) record.get("mapField"); @@ -1185,7 +1161,7 @@ void testConvertJsonToAvroWithNestedComplexTypes() { // Test date conversion in nested structures @Test - void testConvertDatesToTimestampsInNestedStructures() throws ParseException { + void testConvertDatesToTimestampsInNestedStructures() { Map nestedMap = new HashMap<>(); nestedMap.put("nestedDate", "2022-01-01T00:00:00Z"); @@ -1201,14 +1177,19 @@ void testConvertDatesToTimestampsInNestedStructures() throws ParseException { Map result = AvroUtils.convertDatesToTimestamps(inputMap); - // Assert that dates at all levels were converted - assertEquals(1641168000000L, result.get("date"), "Top level date should be converted"); - // assertEquals(1640995200000L, ((Map) result.get("nested")).get("nestedDate"), "Nested date should - // be converted"); + // No conversion is performed anymore (content-sniffing removed) — dates remain untouched at all levels, + // but the recursion still produces a correctly structured (deep-copied) map. + assertEquals("2022-01-03T00:00:00Z", result.get("date"), "Top level date should remain unconverted"); + assertEquals( + "2022-01-01T00:00:00Z", + ((Map) result.get("nested")).get("nestedDate"), + "Nested date should remain unconverted"); List resultList = (List) result.get("list"); - // assertEquals(1641081600000L, ((Map) resultList.get(0)).get("itemDate"), "Date in list item - // should be converted"); + assertEquals( + "2022-01-02T00:00:00Z", + ((Map) resultList.get(0)).get("itemDate"), + "Date in list item should remain unconverted"); } // Test for logical type INT conversions @@ -1262,24 +1243,6 @@ void testConvertJsonToAvroWithInvalidTimeFormat() { "Should throw RuntimeException for invalid time format"); } - // Parameterized test for isDateString method - @ParameterizedTest - @ValueSource( - strings = { - "2022-01-01T00:00:00", - "2022-01-01T00:00:00Z", - "2022-01-01T00:00:00:000", - "2022-01-01T00:00:00:000Z" - }) - void testIsDateStringWithValidFormats(String dateString) { - assertTrue(AvroUtils.isDateString(dateString), "Should recognize valid date format: " + dateString); - } - - @ParameterizedTest - @ValueSource(strings = {"2022-01-01", "00:00:00", "not-a-date", ""}) - void testIsDateStringWithInvalidFormats(String dateString) { - assertFalse(AvroUtils.isDateString(dateString), "Should reject invalid date format: " + dateString); - } // Test edge cases for convertDateStringToDateInt @Test @@ -1422,16 +1385,6 @@ void testGetHeadersMapWithNonStringValues() { assertEquals("true", result.get("booleanHeader"), "Boolean header should be converted to string"); } - // Test date format edge cases for isDateString - @Test - void testIsDateStringWithMalformedDates() { - // Test with malformed date formats - assertFalse(AvroUtils.isDateString("2022-01-01T25:00:00Z"), "Should reject invalid hour"); - assertFalse(AvroUtils.isDateString("2022-01-01T00:60:00Z"), "Should reject invalid minute"); - assertFalse(AvroUtils.isDateString("2022-01-01T00:00:60Z"), "Should reject invalid second"); - assertFalse(AvroUtils.isDateString("2022-13-01T00:00:00Z"), "Should reject invalid month"); - assertFalse(AvroUtils.isDateString("2022-01-32T00:00:00Z"), "Should reject invalid day"); - } @Test void testDeepEqualsWithEqualLists() { @@ -2625,4 +2578,166 @@ void shouldReturnTrue_WhenMultipleExtraExcludedKeysOnlyInExpected() { "Multiple extra excluded keys only in expected — effective sizes both 2, should match"); } } + + @Nested + @DisplayName("Type-driven date comparison") + class TypeDrivenDateComparisonTests { + + @Test + @DisplayName("Should compare literally when both sides are plain strings, even if date-like") + void shouldCompareLiterally_WhenBothSidesAreDateLikeStrings() { + // A genuine Avro `string` field whose content happens to look like a date/timestamp must never be + // converted or reinterpreted — it must be compared as a literal string on both sides. + Map expected = new HashMap<>(); + expected.put("MyDate", "2026-02-10T16:19:14.123Z"); + + Map actual = new HashMap<>(); + actual.put("MyDate", "2026-02-10T16:19:14.123Z"); + + assertTrue(AvroUtils.deepEquals(expected, actual)); + } + + @Test + @DisplayName("Should not match date-like strings that differ, comparing them literally") + void shouldNotMatch_WhenDateLikeStringsDifferLiterally() { + Map expected = new HashMap<>(); + expected.put("MyDate", "2026-02-10T16:19:14.000000Z"); + + Map actual = new HashMap<>(); + actual.put("MyDate", "2026-02-10T16:19:14.123Z"); + + assertFalse(AvroUtils.deepEquals(expected, actual)); + } + + @Test + @DisplayName("Should match an actual Instant (timestamp-micros) against an expected microsecond-precision string") + void shouldMatch_ActualInstant_vs_ExpectedMicrosecondString() { + Instant actualInstant = Instant.parse("2026-02-10T16:19:14.000000Z"); + + Map expected = new HashMap<>(); + expected.put("timestamp", "2026-02-10T16:19:14.000000Z"); + + Map actual = new HashMap<>(); + actual.put("timestamp", actualInstant); + + assertTrue(AvroUtils.deepEquals(expected, actual)); + } + + @Test + @DisplayName("Should match an actual Instant against an expected millisecond-precision string") + void shouldMatch_ActualInstant_vs_ExpectedMillisecondString() { + Instant actualInstant = Instant.parse("2026-02-10T16:19:14.123Z"); + + Map expected = new HashMap<>(); + expected.put("timestamp", "2026-02-10T16:19:14.123Z"); + + Map actual = new HashMap<>(); + actual.put("timestamp", actualInstant); + + assertTrue(AvroUtils.deepEquals(expected, actual)); + } + + @Test + @DisplayName("Should match an actual Instant against an expected string with no fractional seconds") + void shouldMatch_ActualInstant_vs_ExpectedStringWithNoFraction() { + Instant actualInstant = Instant.parse("2026-02-10T16:19:14Z"); + + Map expected = new HashMap<>(); + expected.put("timestamp", "2026-02-10T16:19:14Z"); + + Map actual = new HashMap<>(); + actual.put("timestamp", actualInstant); + + assertTrue(AvroUtils.deepEquals(expected, actual)); + } + + @Test + @DisplayName("Should match an actual LocalDateTime against an expected local-timestamp string") + void shouldMatch_ActualLocalDateTime_vs_ExpectedString() { + LocalDateTime actualLocalDateTime = LocalDateTime.parse("2026-02-10T16:19:14.123456"); + + Map expected = new HashMap<>(); + expected.put("localTimestamp", "2026-02-10T16:19:14.123456"); + + Map actual = new HashMap<>(); + actual.put("localTimestamp", actualLocalDateTime); + + assertTrue(AvroUtils.deepEquals(expected, actual)); + } + + @Test + @DisplayName("Should match an actual LocalDate against an expected yyyy-MM-dd string") + void shouldMatch_ActualLocalDate_vs_ExpectedString() { + LocalDate actualLocalDate = LocalDate.parse("2026-02-10"); + + Map expected = new HashMap<>(); + expected.put("birthDate", "2026-02-10"); + + Map actual = new HashMap<>(); + actual.put("birthDate", actualLocalDate); + + assertTrue(AvroUtils.deepEquals(expected, actual)); + } + + @Test + @DisplayName("Should not match an actual LocalDate against a mismatching expected string") + void shouldNotMatch_ActualLocalDate_vs_MismatchingExpectedString() { + LocalDate actualLocalDate = LocalDate.parse("2026-02-10"); + + Map expected = new HashMap<>(); + expected.put("birthDate", "2026-02-11"); + + Map actual = new HashMap<>(); + actual.put("birthDate", actualLocalDate); + + assertFalse(AvroUtils.deepEquals(expected, actual)); + } + + @Test + @DisplayName("Should apply type-driven date comparison inside nested objects") + void shouldMatch_NestedObjectWithDateField() { + Instant actualInstant = Instant.parse("2026-02-10T16:19:14.123456Z"); + + Map nestedExpected = new HashMap<>(); + nestedExpected.put("timestamp", "2026-02-10T16:19:14.123456Z"); + nestedExpected.put("label", "hello"); + + Map nestedActual = new HashMap<>(); + nestedActual.put("timestamp", actualInstant); + nestedActual.put("label", "hello"); + + Map expected = new HashMap<>(); + expected.put("nested", nestedExpected); + + Map actual = new HashMap<>(); + actual.put("nested", nestedActual); + + assertTrue(AvroUtils.deepEquals(expected, actual)); + } + + @Test + @DisplayName("Should apply type-driven date comparison inside nested lists") + void shouldMatch_NestedListWithDateField() { + Instant actualInstant1 = Instant.parse("2026-02-10T16:19:14.000Z"); + Instant actualInstant2 = Instant.parse("2026-02-11T08:30:00.000000Z"); + + Map item1Expected = new HashMap<>(); + item1Expected.put("timestamp", "2026-02-10T16:19:14Z"); + Map item2Expected = new HashMap<>(); + item2Expected.put("timestamp", "2026-02-11T08:30:00.000000Z"); + + Map item1Actual = new HashMap<>(); + item1Actual.put("timestamp", actualInstant1); + Map item2Actual = new HashMap<>(); + item2Actual.put("timestamp", actualInstant2); + + Map expected = new HashMap<>(); + expected.put("items", List.of(item1Expected, item2Expected)); + + Map actual = new HashMap<>(); + actual.put("items", List.of(item1Actual, item2Actual)); + + assertTrue(AvroUtils.deepEquals(expected, actual)); + } + } } From 3808d6e01365c11639e9177b4a76d1704966593c Mon Sep 17 00:00:00 2001 From: Nil MALHOMME Date: Tue, 4 Aug 2026 14:49:44 +0200 Subject: [PATCH 2/2] (fix/49) Spotless apply --- .../ktestify/utils/serdes/AvroUtils.java | 22 ++++++++----------- .../ktestify/utils/serdes/AvroUtilsTest.java | 6 ++--- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/src/main/java/io/github/ktestify/utils/serdes/AvroUtils.java b/src/main/java/io/github/ktestify/utils/serdes/AvroUtils.java index d08ba54..258869c 100644 --- a/src/main/java/io/github/ktestify/utils/serdes/AvroUtils.java +++ b/src/main/java/io/github/ktestify/utils/serdes/AvroUtils.java @@ -21,8 +21,6 @@ import com.google.gson.reflect.TypeToken; import io.github.ktestify.exceptions.ComparisonException; import io.github.ktestify.exceptions.ProducerException; -import java.io.FileReader; -import java.io.IOException; import java.lang.reflect.Type; import java.math.BigDecimal; import java.math.MathContext; @@ -73,7 +71,6 @@ public final class AvroUtils { private static final Logger LOGGER = LoggerFactory.getLogger(AvroUtils.class); - /** UTC zone formatters for timestamp conversion with optional milliseconds and microseconds. */ private static final DateTimeFormatter TIMESTAMP_FORMATTER_WITH_MS = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss[.SSS]'Z'").withZone(java.time.ZoneOffset.UTC); @@ -576,9 +573,9 @@ private static boolean performDeepEqualsComparison( /** * Compares two values, handling different types including nested objects and lists. * - *

When the actual value is a real Avro logical-type value already resolved by {@link - * io.github.ktestify.utils.serdes.AvroDeserializer} (an {@link Instant}, {@link LocalDateTime}, or {@link - * LocalDate}) and the expected value is a plain {@link String} (typically hand-written JSON with no schema + *

When the actual value is a real Avro logical-type value already resolved by + * {@link io.github.ktestify.utils.serdes.AvroDeserializer} (an {@link Instant}, {@link LocalDateTime}, or + * {@link LocalDate}) and the expected value is a plain {@link String} (typically hand-written JSON with no schema * information), the expected string is parsed into the same target type and compared accordingly. This avoids * guessing whether a string "looks like" a date and instead relies on the actual side's real Java type, which is * only ever a date/time type when the underlying Avro schema declares a logical type. @@ -1306,12 +1303,12 @@ private static GenericData.Array convertJsonArrayToArray(JsonArray jsonA * Recursively traverses a nested map structure (as produced by {@link #convertJsonToMap(String)}). * *

Content-based date sniffing has been intentionally removed (see issue #49): guessing whether a plain string - * "looks like" a date is fundamentally unreliable, since it cannot distinguish a genuine Avro {@code string} field - * whose content happens to look like a date from an actual logical-type date/timestamp field. Date/timestamp - * comparison is now performed in a type-driven way directly in {@link #compareValues(Object, Object, List, - * String)}, based on the actual side's real Java type ({@link Instant}, {@link LocalDateTime}, {@link LocalDate}) - * as resolved by {@link AvroDeserializer}. + * href="https://github.com/ktestify/ktestify-core/issues/49">issue #49): guessing whether a plain string "looks + * like" a date is fundamentally unreliable, since it cannot distinguish a genuine Avro {@code string} field whose + * content happens to look like a date from an actual logical-type date/timestamp field. Date/timestamp comparison + * is now performed in a type-driven way directly in {@link #compareValues(Object, Object, List, String)}, based on + * the actual side's real Java type ({@link Instant}, {@link LocalDateTime}, {@link LocalDate}) as resolved by + * {@link AvroDeserializer}. * *

This method still recurses into nested maps and lists so that the returned map is a structurally-independent * (deep) copy, but performs no value conversion. @@ -1346,7 +1343,6 @@ public static Map convertDatesToTimestamps(Map j return jsonMap; } - /** * Converts a date string to an integer representing days since epoch (1970-01-01). * diff --git a/src/test/java/io/github/ktestify/utils/serdes/AvroUtilsTest.java b/src/test/java/io/github/ktestify/utils/serdes/AvroUtilsTest.java index b30c6e6..bfe1e58 100644 --- a/src/test/java/io/github/ktestify/utils/serdes/AvroUtilsTest.java +++ b/src/test/java/io/github/ktestify/utils/serdes/AvroUtilsTest.java @@ -639,7 +639,6 @@ void convertDatesToTimestamps_ShouldReturnSameMap_WhenInputMapHasNoDateStrings() assertEquals(inputMap, result); } - @Test void convertDateStringToTimestamp_ShouldReturnTimestamp_WhenInputIsDateString() throws ParseException { String dateString = "2022-01-01T00:00:00Z"; @@ -1243,7 +1242,6 @@ void testConvertJsonToAvroWithInvalidTimeFormat() { "Should throw RuntimeException for invalid time format"); } - // Test edge cases for convertDateStringToDateInt @Test void testConvertDateStringToDateIntWithInvalidFormat() { @@ -1385,7 +1383,6 @@ void testGetHeadersMapWithNonStringValues() { assertEquals("true", result.get("booleanHeader"), "Boolean header should be converted to string"); } - @Test void testDeepEqualsWithEqualLists() { String record1 = "{ \"items\": [ { \"id\": 1, \"value\": \"a\" }, { \"id\": 2, \"value\": \"b\" } ] }"; @@ -2610,7 +2607,8 @@ void shouldNotMatch_WhenDateLikeStringsDifferLiterally() { } @Test - @DisplayName("Should match an actual Instant (timestamp-micros) against an expected microsecond-precision string") + @DisplayName( + "Should match an actual Instant (timestamp-micros) against an expected microsecond-precision string") void shouldMatch_ActualInstant_vs_ExpectedMicrosecondString() { Instant actualInstant = Instant.parse("2026-02-10T16:19:14.000000Z");