From e5b29eafa387ab7e1fc9f2d244fd13cc043609e9 Mon Sep 17 00:00:00 2001 From: granadacoder Date: Sat, 20 Jun 2026 15:12:28 -0400 Subject: [PATCH 1/7] Prepare for Jackson-Tools-3 Uplift. (JT3) This PR is NOT the Jackson-Tools-3 Uplift. This PR is adding more unit-tests on java-classes that will (eventually in a future PR) be substantially effected by the JT3 uplift. --- .../model/RequestPartitionIdTest.java | 168 +++++++++++++++++ .../json/jackson/JacksonStructureTest.java | 112 ++++++++++++ .../json/jackson/JacksonWriterTest.java | 169 ++++++++++++++++++ .../FhirResourceDeserializerTest.java | 61 +++++++ .../java/ca/uhn/fhir/util/JsonUtilTest.java | 121 ++++++++++++- 5 files changed, 630 insertions(+), 1 deletion(-) create mode 100644 hapi-fhir-base/src/test/java/ca/uhn/fhir/interceptor/model/RequestPartitionIdTest.java create mode 100644 hapi-fhir-base/src/test/java/ca/uhn/fhir/parser/json/jackson/JacksonStructureTest.java create mode 100644 hapi-fhir-base/src/test/java/ca/uhn/fhir/parser/json/jackson/JacksonWriterTest.java create mode 100644 hapi-fhir-base/src/test/java/ca/uhn/fhir/serializer/FhirResourceDeserializerTest.java diff --git a/hapi-fhir-base/src/test/java/ca/uhn/fhir/interceptor/model/RequestPartitionIdTest.java b/hapi-fhir-base/src/test/java/ca/uhn/fhir/interceptor/model/RequestPartitionIdTest.java new file mode 100644 index 000000000000..5322aeedc269 --- /dev/null +++ b/hapi-fhir-base/src/test/java/ca/uhn/fhir/interceptor/model/RequestPartitionIdTest.java @@ -0,0 +1,168 @@ +package ca.uhn.fhir.interceptor.model; + +import com.google.common.collect.Lists; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.time.LocalDate; +import java.util.stream.Stream; + +import static ca.uhn.fhir.interceptor.model.RequestPartitionId.allPartitions; +import static ca.uhn.fhir.interceptor.model.RequestPartitionId.defaultPartition; +import static ca.uhn.fhir.interceptor.model.RequestPartitionId.fromPartitionId; +import static ca.uhn.fhir.interceptor.model.RequestPartitionId.fromPartitionIds; +import static ca.uhn.fhir.interceptor.model.RequestPartitionId.fromPartitionNames; +import static ca.uhn.fhir.interceptor.model.RequestPartitionId.stringifyForKey; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RequestPartitionIdTest { + + private static final LocalDate PARTITION_DATE = LocalDate.of(2020, 1, 1); + private static final String PARTITION_NAME_1 = "Name1"; + private static final String PARTITION_NAME_2 = "Name2"; + private static final Integer PARTITION_ID_1 = 1; + private static final Integer PARTITION_ID_2 = 2; + private static final Integer PARTITION_ID_123 = 123; + + @Test + void testEqualsAndHashCode() { + RequestPartitionId expected = fromPartitionId(PARTITION_ID_123, PARTITION_DATE); + RequestPartitionId actual = fromPartitionId(PARTITION_ID_123, PARTITION_DATE); + + assertEquals(expected, actual); + assertEquals(expected.hashCode(), actual.hashCode()); + assertNotEquals(expected, "123"); + assertNotEquals(expected, null); + } + + @Test + void testPartitionHelpers() { + IDefaultPartitionSettings settings = new IDefaultPartitionSettings() { + @Override + public Integer getDefaultPartitionId() { + return 0; + } + }; + + assertTrue(fromPartitionId(null).isPartition(null)); + assertTrue(fromPartitionId(null).isDefaultPartition()); + assertThat(fromPartitionId(null).hasDefaultPartitionId(null)).isTrue(); + assertThat(defaultPartition(settings).isPartition(0)).isTrue(); + assertThat(defaultPartition(settings).isDefaultPartition()).isFalse(); + assertThat(allPartitions().isAllPartitions()).isTrue(); + assertThat(allPartitions().isPartition(0)).isFalse(); + assertThat(fromPartitionIds(0, 2).isPartition(0)).isFalse(); + } + + @Test + void testMergeIds() { + assertEquals(fromPartitionIds(1, 2, 3, 4), fromPartitionIds(1, 2, 3).mergeIds(fromPartitionIds(1, 2, 4))); + assertEquals(allPartitions(), allPartitions().mergeIds(fromPartitionIds(1, 2, 4))); + assertEquals(allPartitions(), fromPartitionIds(1, 2, 3).mergeIds(allPartitions())); + assertEquals(fromPartitionIds(1, 2, 3, null), fromPartitionIds(1, 2, 3).mergeIds(fromPartitionId(null))); + } + + @ParameterizedTest + @MethodSource("testStringifyForKeyTestCases") + void testStringifyForKey(RequestPartitionId theRequestPartitionId, String theExpectedString) { + assertEquals(theExpectedString, stringifyForKey(theRequestPartitionId)); + } + + static Stream testStringifyForKeyTestCases() { + return Stream.of( + new Object[] {allPartitions(), "(all)"}, + new Object[] {fromPartitionId(null), "null"}, + new Object[] {fromPartitionIds(1, 2, 3), "1 2 3"}, + new Object[] {fromPartitionIds(null, 2, 3), "null 2 3"}, + new Object[] {RequestPartitionId.allPartitionsWithPartitionIds(1, 2, 3), "(all) 1 2 3"}); + } + + record ContainsTestCase(String description, RequestPartitionId left, RequestPartitionId right, Comparison comparison) { + enum Comparison { + LEFT_CONTAINS_RIGHT, + RIGHT_CONTAINS_LEFT, + EQUAL, + NEITHER; + + boolean expectLeftContainsRight() { + return this == LEFT_CONTAINS_RIGHT || this == EQUAL; + } + + boolean expectRightContainsLeft() { + return this == RIGHT_CONTAINS_LEFT || this == EQUAL; + } + + boolean expectEqual() { + return this == EQUAL; + } + } + + @Override + public String toString() { + return "%s: %s %s".formatted(description, left, right); + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("getContainsTestCases") + void testContains(ContainsTestCase theTestCase) { + RequestPartitionId left = theTestCase.left(); + RequestPartitionId right = theTestCase.right(); + ContainsTestCase.Comparison comparison = theTestCase.comparison(); + + assertThat(left.contains(right)).describedAs("%s: left contains right", theTestCase.description).isEqualTo(comparison.expectLeftContainsRight()); + assertThat(right.contains(left)).describedAs("%s: right contains left", theTestCase.description).isEqualTo(comparison.expectRightContainsLeft()); + assertThat(right.equals(left)).describedAs("%s: are equal", theTestCase.description).isEqualTo(comparison.expectEqual()); + } + + static ContainsTestCase[] getContainsTestCases() { + IDefaultPartitionSettings nullDefaultPartition = new IDefaultPartitionSettings() {}; + return new ContainsTestCase[] { + new ContainsTestCase("all vs all", allPartitions(), allPartitions(), ContainsTestCase.Comparison.EQUAL), + new ContainsTestCase("all vs normal", allPartitions(), fromPartitionIds(1, 2, 3), ContainsTestCase.Comparison.LEFT_CONTAINS_RIGHT), + new ContainsTestCase("equal partition id lists", fromPartitionIds(1, 2, 3), fromPartitionIds(1, 2, 3), ContainsTestCase.Comparison.EQUAL), + new ContainsTestCase("different id lists incomparable", fromPartitionIds(1, 2, 5), fromPartitionIds(1, 2, 9), ContainsTestCase.Comparison.NEITHER), + new ContainsTestCase("default as null contains", fromPartitionIds(1, 2, null), defaultPartition(nullDefaultPartition), ContainsTestCase.Comparison.LEFT_CONTAINS_RIGHT), + new ContainsTestCase("names equivalent", fromPartitionNames("A", "B"), fromPartitionNames("A", "B"), ContainsTestCase.Comparison.EQUAL), + new ContainsTestCase("names left contains right", fromPartitionNames("A", "B"), fromPartitionNames("A"), ContainsTestCase.Comparison.LEFT_CONTAINS_RIGHT), + new ContainsTestCase("names left ids right", fromPartitionNames("A", "B"), fromPartitionIds(1, 2), ContainsTestCase.Comparison.NEITHER) + }; + } + + @Test + void testJsonRoundTripPreservesSemanticFields() throws Exception { + RequestPartitionId start = RequestPartitionId.forPartitionIdsAndNames(Lists.newArrayList(PARTITION_NAME_1, PARTITION_NAME_2), Lists.newArrayList(PARTITION_ID_1, PARTITION_ID_2), PARTITION_DATE); + + RequestPartitionId end = assertSerDeserSer(start); + + assertThat(end.getPartitionDate()).isEqualTo(PARTITION_DATE); + assertThat(end.getPartitionNames()).containsExactly(PARTITION_NAME_1, PARTITION_NAME_2); + assertThat(end.getPartitionIds()).containsExactly(PARTITION_ID_1, PARTITION_ID_2); + assertThat(end.getPartitionIdsWithoutDefault()).containsExactly(PARTITION_ID_1, PARTITION_ID_2); + assertThat(end.getFirstPartitionIdOrNull()).isEqualTo(PARTITION_ID_1); + assertThat(end.getFirstPartitionNameOrNull()).isEqualTo(PARTITION_NAME_1); + } + + @Test + void testJsonRoundTripPreservesAllAndDefaultPartitions() throws Exception { + RequestPartitionId all = assertSerDeserSer(allPartitions()); + assertThat(all.isAllPartitions()).isTrue(); + assertThat(all.hasPartitionIds()).isFalse(); + + RequestPartitionId defaultPartition = assertSerDeserSer(fromPartitionId(null)); + assertThat(defaultPartition.getPartitionIds()).containsExactly((Integer) null); + assertThat(defaultPartition.isDefaultPartition()).isTrue(); + } + + private RequestPartitionId assertSerDeserSer(RequestPartitionId start) throws Exception { + String json = start.asJson(); + RequestPartitionId end = RequestPartitionId.fromJson(json); + assertEquals(start, end); + assertEquals(start.asJson(), end.asJson()); + return end; + } +} diff --git a/hapi-fhir-base/src/test/java/ca/uhn/fhir/parser/json/jackson/JacksonStructureTest.java b/hapi-fhir-base/src/test/java/ca/uhn/fhir/parser/json/jackson/JacksonStructureTest.java new file mode 100644 index 000000000000..ec074d00c7a7 --- /dev/null +++ b/hapi-fhir-base/src/test/java/ca/uhn/fhir/parser/json/jackson/JacksonStructureTest.java @@ -0,0 +1,112 @@ +package ca.uhn.fhir.parser.json.jackson; + +import ca.uhn.fhir.parser.DataFormatException; +import ca.uhn.fhir.parser.json.BaseJsonLikeArray; +import ca.uhn.fhir.parser.json.BaseJsonLikeObject; +import ca.uhn.fhir.parser.json.BaseJsonLikeValue; +import ca.uhn.fhir.parser.json.JsonLikeStructure; +import org.junit.jupiter.api.Test; + +import java.io.StringReader; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class JacksonStructureTest { + + private static final String STRING_FIELD = "string-value"; + private static final String NUMBER_FIELD = "number-value"; + private static final String BOOLEAN_FIELD = "boolean-value"; + private static final String OBJECT_FIELD = "object-value"; + private static final String ARRAY_FIELD = "array-value"; + private static final String STRING_VALUE = "text"; + private static final String NUMBER_VALUE = "123"; + private static final String BOOLEAN_VALUE = "true"; + private static final String NULL_FIELD = "null-value"; + private static final String NESTED_FIELD = "nested"; + private static final String NESTED_VALUE = "value"; + private static final int ARRAY_VALUE_ONE = 1; + private static final int ARRAY_VALUE_TWO = 2; + + @Test + void getInstanceReturnsNewJacksonStructure() { + JacksonStructure structure = new JacksonStructure(); + + JsonLikeStructure instance = structure.getInstance(); + + assertThat(instance).isInstanceOf(JacksonStructure.class); + assertThat(instance).isNotSameAs(structure); + } + + @Test + void loadsObjectRootAndExposesExpectedValueTypes() { + JacksonStructure structure = new JacksonStructure(); + + structure.load(new StringReader(""" + { + "%s": "%s", + "%s": %s, + "%s": %s, + "%s": null, + "%s": {"%s": "%s"}, + "%s": [%d, {"%s": %d}] + } + """.formatted(STRING_FIELD, STRING_VALUE, NUMBER_FIELD, NUMBER_VALUE, BOOLEAN_FIELD, BOOLEAN_VALUE, NULL_FIELD, OBJECT_FIELD, NESTED_FIELD, NESTED_VALUE, ARRAY_FIELD, ARRAY_VALUE_ONE, NESTED_FIELD, ARRAY_VALUE_TWO))); + + BaseJsonLikeObject root = structure.getRootObject(); + assertThat(root).isNotNull(); + + assertThat(root.get(STRING_FIELD)) + .extracting(BaseJsonLikeValue::getJsonType, BaseJsonLikeValue::getDataType, BaseJsonLikeValue::getAsString) + .containsExactly(BaseJsonLikeValue.ValueType.SCALAR, BaseJsonLikeValue.ScalarType.STRING, STRING_VALUE); + + assertThat(root.get(NUMBER_FIELD)) + .extracting(BaseJsonLikeValue::getJsonType, BaseJsonLikeValue::getDataType, BaseJsonLikeValue::getAsString) + .containsExactly(BaseJsonLikeValue.ValueType.SCALAR, BaseJsonLikeValue.ScalarType.NUMBER, NUMBER_VALUE); + + assertThat(root.get(BOOLEAN_FIELD)) + .extracting(BaseJsonLikeValue::getJsonType, BaseJsonLikeValue::getDataType, BaseJsonLikeValue::getAsBoolean) + .containsExactly(BaseJsonLikeValue.ValueType.SCALAR, BaseJsonLikeValue.ScalarType.BOOLEAN, true); + + assertThat(root.get(NULL_FIELD).isNull()).isTrue(); + + BaseJsonLikeObject nestedObject = root.get(OBJECT_FIELD).getAsObject(); + assertThat(nestedObject).isNotNull(); + assertThat(nestedObject.get(NESTED_FIELD).getAsString()).isEqualTo(NESTED_VALUE); + + BaseJsonLikeArray array = root.get(ARRAY_FIELD).getAsArray(); + assertThat(array).isNotNull(); + assertThat(array.size()).isEqualTo(2); + assertThat(array.get(0).getAsNumber()).isEqualTo(ARRAY_VALUE_ONE); + assertThat(array.get(1).getAsObject().get("nested").getAsNumber()).isEqualTo(ARRAY_VALUE_TWO); + } + + @Test + void loadsArrayRootWhenAllowedAndRejectsItFromGetRootObject() { + JacksonStructure structure = new JacksonStructure(); + + structure.load(new StringReader("[\"a\", 2, false]"), true); + + assertThatThrownBy(structure::getRootObject) + .isInstanceOf(DataFormatException.class) + .hasMessageContaining("must start with '{'"); + } + + @Test + void rejectsArrayRootWhenNotAllowed() { + JacksonStructure structure = new JacksonStructure(); + + assertThatThrownBy(() -> structure.load(new StringReader("[1, 2, 3]"))) + .isInstanceOf(DataFormatException.class) + .hasMessageContaining("must be '{'"); + } + + @Test + void rejectsMalformedJsonContent() { + JacksonStructure structure = new JacksonStructure(); + + assertThatThrownBy(() -> structure.load(new StringReader("{\"resourceType\":\"Patient\"} trailing"))) + .isInstanceOf(DataFormatException.class) + .hasMessageContaining("Failed to parse JSON encoded FHIR content"); + } +} diff --git a/hapi-fhir-base/src/test/java/ca/uhn/fhir/parser/json/jackson/JacksonWriterTest.java b/hapi-fhir-base/src/test/java/ca/uhn/fhir/parser/json/jackson/JacksonWriterTest.java new file mode 100644 index 000000000000..59ee80e577e9 --- /dev/null +++ b/hapi-fhir-base/src/test/java/ca/uhn/fhir/parser/json/jackson/JacksonWriterTest.java @@ -0,0 +1,169 @@ +package ca.uhn.fhir.parser.json.jackson; + +import ca.uhn.fhir.parser.json.BaseJsonLikeWriter; +import com.fasterxml.jackson.core.JsonFactory; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.Writer; +import java.math.BigDecimal; +import java.math.BigInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +class JacksonWriterTest { + + private static final String STRING_FIELD = "string"; + private static final String BIG_INTEGER_FIELD = "bigInteger"; + private static final String BIG_DECIMAL_FIELD = "bigDecimal"; + private static final String LONG_FIELD = "long"; + private static final String DOUBLE_FIELD = "double"; + private static final String BOOLEAN_OBJECT_FIELD = "booleanObject"; + private static final String BOOLEAN_PRIMITIVE_FIELD = "booleanPrimitive"; + private static final String NULLABLE_BOOLEAN_FIELD = "nullableBoolean"; + private static final String NULLABLE_STRING_FIELD = "nullableString"; + private static final String ARRAY_FIELD = "array"; + private static final String NESTED_FIELD = "nested"; + private static final String OBJECT_FIELD = "object"; + private static final String INNER_FIELD = "inner"; + private static final String TEXT_VALUE = "text"; + private static final String STRING_VALUE = "hello"; + private static final String BIG_INTEGER_VALUE = "12345678901234567890"; + private static final String BIG_DECIMAL_VALUE = "123.4500"; + private static final long LONG_VALUE = 42L; + private static final double DOUBLE_VALUE = 3.14d; + private static final String NESTED_VALUE = "value"; + private static final String EXPECTED_COMPACT_JSON = String.format( + "{\"%s\":\"%s\",\"%s\":%s,\"%s\":%s,\"%s\":%s,\"%s\":%s,\"%s\":true,\"%s\":false,\"%s\":null,\"%s\":null,\"%s\":[\"%s\",2,3.45,4,5.5,true,false,null,{\"%s\":\"%s\"}],\"%s\":{\"%s\":\"%s\"}}", + STRING_FIELD, + STRING_VALUE, + BIG_INTEGER_FIELD, + BIG_INTEGER_VALUE, + BIG_DECIMAL_FIELD, + BIG_DECIMAL_VALUE, + LONG_FIELD, + LONG_VALUE, + DOUBLE_FIELD, + DOUBLE_VALUE, + BOOLEAN_OBJECT_FIELD, + BOOLEAN_PRIMITIVE_FIELD, + NULLABLE_BOOLEAN_FIELD, + NULLABLE_STRING_FIELD, + ARRAY_FIELD, + TEXT_VALUE, + NESTED_FIELD, + NESTED_VALUE, + OBJECT_FIELD, + INNER_FIELD, + NESTED_VALUE); + + @Test + void writesCompactJsonAcrossAllWriterOverloads() throws IOException { + TrackingWriter trackingWriter = new TrackingWriter(); + JacksonWriter writer = new JacksonWriter(new JsonFactory(), trackingWriter); + + writer.init() + .beginObject() + .write(STRING_FIELD, STRING_VALUE) + .write(BIG_INTEGER_FIELD, new BigInteger(BIG_INTEGER_VALUE)) + .write(BIG_DECIMAL_FIELD, new BigDecimal(BIG_DECIMAL_VALUE)) + .write(LONG_FIELD, LONG_VALUE) + .write(DOUBLE_FIELD, DOUBLE_VALUE) + .write(BOOLEAN_OBJECT_FIELD, Boolean.TRUE) + .write(BOOLEAN_PRIMITIVE_FIELD, false) + .write(NULLABLE_BOOLEAN_FIELD, (Boolean) null) + .write(NULLABLE_STRING_FIELD, (String) null) + .beginArray(ARRAY_FIELD) + .write(TEXT_VALUE) + .write(new BigInteger("2")) + .write(new BigDecimal("3.45")) + .write(4L) + .write(5.5d) + .write(Boolean.TRUE) + .write(false) + .writeNull() + .beginObject() + .write(NESTED_FIELD, NESTED_VALUE) + .endObject() + .endArray() + .beginObject(OBJECT_FIELD) + .write(INNER_FIELD, NESTED_VALUE) + .endObject() + .endObject(); + writer.close(); + + String actualJson = trackingWriter.toString(); + assertThat(actualJson).isEqualTo(EXPECTED_COMPACT_JSON); + } + + @Test + void prettyPrintUsesConfiguredIndentationAndLineEndings() throws IOException { + TrackingWriter trackingWriter = new TrackingWriter(); + JacksonWriter writer = new JacksonWriter(new JsonFactory(), trackingWriter); + writer.setPrettyPrint(true); + + writer.init() + .beginObject() + .write("alpha", "beta") + .beginObject("nested") + .write("gamma", 1L) + .endObject() + .endObject(); + writer.close(); + + assertThat(trackingWriter.toString()) + .isEqualTo("{\n \"alpha\": \"beta\",\n \"nested\": {\n \"gamma\": 1\n }\n}"); + } + + @Test + void closeDoesNotCloseUnderlyingWriterWhenUsingJacksonStructureFactory() throws IOException { + TrackingWriter trackingWriter = new TrackingWriter(); + BaseJsonLikeWriter writer = new JacksonStructure().getJsonLikeWriter(trackingWriter); + + writer.init() + .beginObject() + .write("status", "ok") + .endObject(); + writer.close(); + + assertThat(trackingWriter.isClosed()).isFalse(); + assertThat(trackingWriter.toString()).isEqualTo("{\"status\":\"ok\"}"); + } + + @Test + void initAndFlushAreChainable() throws IOException { + TrackingWriter trackingWriter = new TrackingWriter(); + JacksonWriter writer = new JacksonWriter(new JsonFactory(), trackingWriter); + + assertThat(writer.init()).isSameAs(writer); + assertThat(writer.flush()).isSameAs(writer); + } + + private static class TrackingWriter extends Writer { + + private final StringBuilder myBuffer = new StringBuilder(); + private boolean myClosed; + + @Override + public void write(char[] theChars, int theOffset, int theLength) { + myBuffer.append(theChars, theOffset, theLength); + } + + @Override + public void flush() {} + + @Override + public void close() { + myClosed = true; + } + + boolean isClosed() { + return myClosed; + } + + @Override + public String toString() { + return myBuffer.toString(); + } + } +} diff --git a/hapi-fhir-base/src/test/java/ca/uhn/fhir/serializer/FhirResourceDeserializerTest.java b/hapi-fhir-base/src/test/java/ca/uhn/fhir/serializer/FhirResourceDeserializerTest.java new file mode 100644 index 000000000000..6c56c1e60828 --- /dev/null +++ b/hapi-fhir-base/src/test/java/ca/uhn/fhir/serializer/FhirResourceDeserializerTest.java @@ -0,0 +1,61 @@ +package ca.uhn.fhir.serializer; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.parser.IParser; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class FhirResourceDeserializerTest { + + private static final String RESOURCE_JSON = """ + { + "resourceType": "Patient", + "id": "patient-123", + "active": true, + "name": [ + { + "family": "Example", + "given": ["Pat"] + } + ] + } + """; + private static final String EXPECTED_MINIFIED_JSON = "{\"resourceType\":\"Patient\",\"id\":\"patient-123\",\"active\":true,\"name\":[{\"family\":\"Example\",\"given\":[\"Pat\"]}]}"; + + @Test + void deserializesIBaseResourceThroughRegisteredDeserializer() throws IOException { + FhirContext fhirContext = mock(FhirContext.class); + IParser parser = mock(IParser.class); + IBaseResource resource = mock(IBaseResource.class); + DeserializationContext deserializationContext = mock(DeserializationContext.class); + + when(fhirContext.newJsonParser()).thenReturn(parser); + when(parser.setPrettyPrint(true)).thenReturn(parser); + when(parser.parseResource(EXPECTED_MINIFIED_JSON)).thenReturn(resource); + FhirResourceDeserializer deserializer = new FhirResourceDeserializer(fhirContext); + + JsonFactory jsonFactory = new JsonFactory(); + try (JsonParser jsonParser = jsonFactory.createParser(RESOURCE_JSON)) { + jsonParser.setCodec(new ObjectMapper()); + + IBaseResource actual = deserializer.deserialize(jsonParser, deserializationContext); + + assertThat(actual).isSameAs(resource); + } + + verify(parser).setPrettyPrint(true); + verify(parser).parseResource(EXPECTED_MINIFIED_JSON); + verify(fhirContext).newJsonParser(); + } +} diff --git a/hapi-fhir-base/src/test/java/ca/uhn/fhir/util/JsonUtilTest.java b/hapi-fhir-base/src/test/java/ca/uhn/fhir/util/JsonUtilTest.java index f737dba00b6a..2a16a03ac755 100644 --- a/hapi-fhir-base/src/test/java/ca/uhn/fhir/util/JsonUtilTest.java +++ b/hapi-fhir-base/src/test/java/ca/uhn/fhir/util/JsonUtilTest.java @@ -10,10 +10,18 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.Writer; +import java.nio.charset.StandardCharsets; import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.time.ZonedDateTime; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -21,7 +29,7 @@ class JsonUtilTest { - private static final Logger ourLog = LoggerFactory.getLogger(JsonUtil.class); + private static final Logger ourLog = LoggerFactory.getLogger(JsonUtilTest.class); @Test public void testSensitiveNoDisplayAnnotationIsHiddenFromBasicSerialization() { @@ -81,6 +89,77 @@ public void testSerializeOrInvalidRequest_withNonSerializableObject_throwsInvali } } + @Test + void testDeserializeList_withTypedPayload_roundTripsExpectedFields() throws IOException { + String json = "[{\"publicField\":\"first\"},{\"publicField\":\"second\",\"sensitiveField\":\"masked\"}]"; + + List result = JsonUtil.deserializeList(json, TestObject.class); + + assertEquals(2, result.size()); + assertEquals("first", result.get(0).getPublicField()); + assertNull(result.get(0).getPrivateField()); + assertEquals("second", result.get(1).getPublicField()); + assertEquals("masked", result.get(1).getPrivateField()); + } + + @Test + void testDeserializeList_withJavaTimePayload_roundTripsExpectedValues() throws IOException { + String json = """ + [ + {"when":"2026-06-19T21:54:24-04:00"}, + {"when":"2026-06-20T01:54:24Z"} + ] + """; + + List result = JsonUtil.deserializeList(json, TemporalHolder.class); + + assertEquals(2, result.size()); + assertThat(result.get(0).getWhen().toInstant()).isEqualTo(ZonedDateTime.parse("2026-06-19T21:54:24-04:00").toInstant()); + assertThat(result.get(1).getWhen().toInstant()).isEqualTo(ZonedDateTime.parse("2026-06-20T01:54:24Z").toInstant()); + } + + @Test + void testDeserialize_fromInputStream_deserializesExpectedObject() throws IOException { + String json = "{\"publicField\":\"streamed\"}"; + ByteArrayInputStream inputStream = new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)); + + TestObject result = JsonUtil.deserialize(inputStream, TestObject.class); + + assertEquals("streamed", result.getPublicField()); + assertNull(result.getPrivateField()); + } + + @Test + void testSerialize_withPrettyPrintToggle_andNullExclusion() { + Map payload = new LinkedHashMap<>(); + payload.put("alpha", "beta"); + payload.put("nullValue", null); + + String pretty = JsonUtil.serialize(payload, true); + String nonPretty = JsonUtil.serialize(payload, false); + + assertThat(pretty).contains("\n").contains("\"alpha\"").doesNotContain("nullValue"); + assertEquals("{\"alpha\":\"beta\"}", nonPretty); + } + + @Test + void testSerializeToWriter_doesNotCloseWriter_andPreservesJsonContent() throws IOException { + TrackingWriter trackingWriter = new TrackingWriter(); + TemporalHolder payload = new TemporalHolder(); + payload.setWhen(ZonedDateTime.parse("2026-06-19T21:54:24-04:00")); + + JsonUtil.serialize(payload, trackingWriter); + + assertThat(trackingWriter.isClosed()).isFalse(); + TemporalHolder decoded = JsonUtil.deserialize(trackingWriter.toString(), TemporalHolder.class); + assertThat(decoded.getWhen().toInstant()).isEqualTo(payload.getWhen().toInstant()); + } + + @Test + void testDeserialize_rejectsBlankInput() { + assertThatThrownBy(() -> JsonUtil.deserialize(" ", Map.class)).isInstanceOf(IllegalArgumentException.class); + } + @JsonFilter(IModelJson.SENSITIVE_DATA_FILTER_NAME) static class TestObject implements IModelJson { @@ -116,4 +195,44 @@ public NonSerializableObject(String theValue) { myValue = theValue; } } + + static class TemporalHolder { + @JsonProperty("when") + private ZonedDateTime myWhen; + + public ZonedDateTime getWhen() { + return myWhen; + } + + public void setWhen(ZonedDateTime theWhen) { + myWhen = theWhen; + } + } + + private static class TrackingWriter extends Writer { + private final StringBuilder myBuffer = new StringBuilder(); + private boolean myClosed; + + @Override + public void write(char[] theChars, int theOffset, int theLength) { + myBuffer.append(theChars, theOffset, theLength); + } + + @Override + public void flush() {} + + @Override + public void close() { + myClosed = true; + } + + boolean isClosed() { + return myClosed; + } + + @Override + public String toString() { + return myBuffer.toString(); + } + } } From fc1e897ceeb23215b945c40b13222a63e9365979 Mon Sep 17 00:00:00 2001 From: granadacoder Date: Sun, 21 Jun 2026 09:03:13 -0400 Subject: [PATCH 2/7] Add more unit-tests in anticipation of future (JT3) uplift that will remove (deprecated/abandoned) : com.github.fge.jsonpatch --- .../util/jsonpatch/JsonPatchUtilsTest.java | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/util/jsonpatch/JsonPatchUtilsTest.java b/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/util/jsonpatch/JsonPatchUtilsTest.java index 838f8664991c..32d6653cb405 100644 --- a/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/util/jsonpatch/JsonPatchUtilsTest.java +++ b/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/util/jsonpatch/JsonPatchUtilsTest.java @@ -6,11 +6,13 @@ import ca.uhn.fhir.jpa.test.BaseJpaR4Test; import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; import org.hl7.fhir.r4.model.Observation; +import org.hl7.fhir.r4.model.Group; import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.fail; public class JsonPatchUtilsTest extends BaseJpaR4Test { @@ -90,6 +92,72 @@ public void testPatchAddArray() { assertThat(outcome).contains("\"reference\": \"Media/465eb73a-bce3-423a-b86e-5d0d267638f4\""); } + @Test + public void testPatchAddMemberToEmptyGroup() { + Group group = new Group(); + group.setId("Group/test-group"); + group.setType(Group.GroupType.PERSON); + group.setActual(true); + + String patchText = "[{" + + "\"op\":\"add\"," + + "\"path\":\"/member/0\"," + + "\"value\":{" + + "\"entity\":{\"reference\":\"Patient/123\"}," + + "\"inactive\":false" + + "}" + + "}]"; + + Group result = JsonPatchUtils.apply(myFhirContext, group, patchText); + + assertThat(result.getMember()).hasSize(1); + assertThat(result.getMember().get(0).getEntity().getReference()).isEqualTo("Patient/123"); + assertThat(result.getMember().get(0).getInactive()).isFalse(); + } + + @Test + public void testPatchAddMemberToEmptyGroup_AppendPath() { + Group group = new Group(); + group.setId("Group/test-group"); + group.setType(Group.GroupType.PERSON); + group.setActual(true); + + String patchText = "[{" + + "\"op\":\"add\"," + + "\"path\":\"/member/-\"," + + "\"value\":{" + + "\"entity\":{\"reference\":\"Patient/456\"}," + + "\"inactive\":false" + + "}" + + "}]"; + + Group result = JsonPatchUtils.apply(myFhirContext, group, patchText); + + assertThat(result.getMember()).hasSize(1); + assertThat(result.getMember().get(0).getEntity().getReference()).isEqualTo("Patient/456"); + } + + @Test + public void testPatchReplaceMemberOnEmptyGroup_Fails() { + Group group = new Group(); + group.setId("Group/test-group"); + group.setType(Group.GroupType.PERSON); + group.setActual(true); + + String patchText = "[{" + + "\"op\":\"replace\"," + + "\"path\":\"/member/0\"," + + "\"value\":{" + + "\"entity\":{\"reference\":\"Patient/123\"}," + + "\"inactive\":false" + + "}" + + "}]"; + + assertThatThrownBy(() -> JsonPatchUtils.apply(myFhirContext, group, patchText)) + .isInstanceOf(InvalidRequestException.class) + .hasMessageContaining("HAPI-1272"); + } + @Test public void testPatchAddInvalidElement() { From e336596be8427a114780c68604938c2e52b691b3 Mon Sep 17 00:00:00 2001 From: granadacoder Date: Sat, 27 Jun 2026 18:32:53 -0400 Subject: [PATCH 3/7] More "looking forward to Jackson-Tools-3" unit-tests. --- .../src/test/java/ChangelogMigratorTest.java | 581 ++++++++++++++++++ ...ModifiedMessagePersistenceSvcImplTest.java | 7 + .../FhirResourceSerializerTest.java | 361 +++++++++++ .../FhirResourceSerializerTest.java | 361 +++++++++++ 4 files changed, 1310 insertions(+) create mode 100644 hapi-fhir-docs/src/test/java/ChangelogMigratorTest.java create mode 100644 hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/subscription/ResourceModifiedMessagePersistenceSvcImplTest.java create mode 100644 hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/serializer/FhirResourceSerializerTest.java create mode 100644 hapi-fhir-structures-r5/src/test/java/ca/uhn/fhir/serializer/FhirResourceSerializerTest.java diff --git a/hapi-fhir-docs/src/test/java/ChangelogMigratorTest.java b/hapi-fhir-docs/src/test/java/ChangelogMigratorTest.java new file mode 100644 index 000000000000..fccfdd096a24 --- /dev/null +++ b/hapi-fhir-docs/src/test/java/ChangelogMigratorTest.java @@ -0,0 +1,581 @@ +/*- + * ChangelogMigratorTest.java + * + * LFJT3 — "Looking Forward to Jackson Tools 3" + * ============================================= + * This test file is written against Jackson 2 (com.fasterxml.jackson / + * jackson-dataformat-yaml) and is structured so that migrating to Jackson 3 + * (tools.jackson) requires changes ONLY in the clearly marked + * "── LFJT3 JACKSON IMPORT BLOCK ──" and "── LFJT3 MAPPER FACTORY ──" + * sections. All assertions operate on plain String / Map / List values and + * are Jackson-version-agnostic. + * + * LFJT3 MIGRATION CHECKLIST FOR THIS FILE + * ---------------------------------------- + * [ ] Swap the import block (see "── LFJT3 JACKSON IMPORT BLOCK ──") + * [ ] Swap the createChangesMapper() factory (see "── LFJT3 MAPPER FACTORY ──") + * [ ] Swap the createVersionMapper() factory (see "── LFJT3 MAPPER FACTORY ──") + * [ ] All @Test methods — NO CHANGES NEEDED + * + * DESIGN NOTE — WHY ChangelogMigrator IS HARD TO TEST AS-IS + * ---------------------------------------------------------- + * ChangelogMigrator.main() hardcodes two filesystem paths: + * - Input: "src/changes/changes.xml" + * - Output: "hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/" + * There are no public/package-private methods and no dependency injection. + * To achieve full integration coverage without refactoring, this test: + * (a) Tests YAML serialization directly (instantiating the mapper the same + * way ChangelogMigrator does) + * (b) Tests the item-map building logic with plain Java (no Jackson needed) + * (c) Provides an extractable integration test that runs against a temp dir + * by replicating the ChangelogMigrator logic with injected paths — + * a pattern that mirrors what ChangelogMigrator would look like after + * a recommended refactor to accept paths as arguments. + */ + +// NOTE: ChangelogMigrator is in the DEFAULT PACKAGE (no package declaration). +// This test is also in the default package so it can reference the class directly. + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +// ── LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────────── +// ONLY this block changes during the LFJT3 uplift. +// +// Jackson 2 (NOW): +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; +// +// Jackson 3 (LFJT3) — replace the three lines above with: +// import tools.jackson.databind.ObjectMapper; +// import tools.jackson.dataformat.yaml.YAMLMapper; +// import tools.jackson.dataformat.yaml.YAMLWriteFeature; +// ── END LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────── + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.StringWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ChangelogMigratorTest { + + // ── LFJT3 MAPPER FACTORY ───────────────────────────────────────────────── + // These two methods are the ONLY other change points during LFJT3 uplift. + // + // Jackson 2 (NOW): + // new ObjectMapper(new YAMLFactory().disable(YAMLGenerator.Feature.SPLIT_LINES)) + // new ObjectMapper(new YAMLFactory()) + // + // Jackson 3 (LFJT3) — replace with: + // YAMLMapper.builder().disable(YAMLWriteFeature.SPLIT_LINES).build() + // YAMLMapper.builder().build() + + /** + * Mirrors the mapper construction in ChangelogMigrator for changes.yaml. + * LFJT3: replace body with YAMLMapper.builder().disable(YAMLWriteFeature.SPLIT_LINES).build() + */ + private static ObjectMapper createChangesMapper() { + YAMLFactory yf = new YAMLFactory().disable(YAMLGenerator.Feature.SPLIT_LINES); + return new ObjectMapper(yf); + } + + /** + * Mirrors the mapper construction in ChangelogMigrator for version.yaml. + * LFJT3: replace body with YAMLMapper.builder().build() + */ + private static ObjectMapper createVersionMapper() { + return new ObjectMapper(new YAMLFactory()); + } + // ── END LFJT3 MAPPER FACTORY ───────────────────────────────────────────── + + + // ───────────────────────────────────────────────────────────────────────── + // 1. YAML MAPPER CONFIGURATION + // Verifies that SPLIT_LINES is disabled — the core Jackson config in + // ChangelogMigrator. Long strings must NOT be split across lines. + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("YAML mapper configuration") + class YamlMapperConfigurationTests { + + @Test + @DisplayName("changes.yaml mapper does not split long strings across lines (SPLIT_LINES disabled)") + void changesMapper_doesNotSplitLongStrings() throws IOException { + ObjectMapper mapper = createChangesMapper(); + + // Build an item with a long title — this would be split if SPLIT_LINES were enabled + String longTitle = "This is a very long changelog title that exceeds 80 characters and would " + + "normally be split across multiple lines if SPLIT_LINES were enabled in the YAMLFactory"; + + HashMap itemMap = new HashMap<>(); + itemMap.put("type", "add"); + itemMap.put("title", longTitle); + HashMap itemRoot = new HashMap<>(); + itemRoot.put("item", itemMap); + + StringWriter sw = new StringWriter(); + mapper.writeValue(sw, List.of(itemRoot)); + String yaml = sw.toString(); + + // The title value must appear on a single line — no folded/literal block scalars + assertThat(yaml) + .as("Long title must not be split across lines (SPLIT_LINES must be disabled)") + .contains(longTitle.substring(0, 40)); // first 40 chars on same line as key + assertThat(yaml.lines().filter(l -> l.contains("title")).count()) + .as("'title' key must appear exactly once (not folded across lines)") + .isEqualTo(1L); + } + + @Test + @DisplayName("version.yaml mapper produces valid YAML") + void versionMapper_producesValidYaml() throws IOException { + ObjectMapper mapper = createVersionMapper(); + HashMap versionMap = new HashMap<>(); + versionMap.put("release-date", "2024-01-15"); + versionMap.put("codename", "Test Release"); + + StringWriter sw = new StringWriter(); + assertDoesNotThrow(() -> mapper.writeValue(sw, versionMap)); + + String yaml = sw.toString(); + assertThat(yaml).contains("release-date"); + assertThat(yaml).contains("2024-01-15"); + } + } + + + // ───────────────────────────────────────────────────────────────────────── + // 2. changes.yaml STRUCTURE + // Verifies the exact YAML structure ChangelogMigrator produces for + // the items list. + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("changes.yaml output structure") + class ChangesYamlStructureTests { + + @Test + @DisplayName("Item with type and title produces correct YAML keys") + void changesYaml_itemWithTypeAndTitle_hasCorrectKeys() throws IOException { + ObjectMapper mapper = createChangesMapper(); + + List items = buildItems( + buildItem("add", "HAPI-123", "A new feature was added")); + + String yaml = serializeToString(mapper, items); + + // Check structural keys appear in the YAML + assertThat(yaml).contains("item:"); + assertThat(yaml).contains("title:"); + + // Round-trip parse to assert VALUES without quoting sensitivity. + // Jackson 2 quotes strings (type: "add"); LFJT3 may differ. + // Parsing back is quoting-agnostic and safe for both versions. + List> parsed = mapper.readValue(yaml, List.class); + Map item = (Map) parsed.get(0).get("item"); + assertThat(item.get("type")).isEqualTo("add"); + assertThat(item.get("issue")).isEqualTo("HAPI-123"); + } + + @Test + @DisplayName("Item without issue omits the issue key entirely") + void changesYaml_itemWithoutIssue_omitsIssueKey() throws IOException { + ObjectMapper mapper = createChangesMapper(); + + List items = buildItems(buildItemNoIssue("fix", "A bug was fixed")); + + String yaml = serializeToString(mapper, items); + + assertThat(yaml).contains("title:"); + assertThat(yaml).doesNotContain("issue:"); + + // Round-trip to verify type value without quoting sensitivity + List> parsed = mapper.readValue(yaml, List.class); + Map item = (Map) parsed.get(0).get("item"); + assertThat(item.get("type")).isEqualTo("fix"); + } + + @Test + @DisplayName("Multiple items produce multiple YAML entries") + void changesYaml_multipleItems_producesMultipleEntries() throws IOException { + ObjectMapper mapper = createChangesMapper(); + + List items = buildItems( + buildItem("add", "HAPI-1", "First feature"), + buildItem("fix", "HAPI-2", "Second fix"), + buildItemNoIssue("change", "Third change")); + + String yaml = serializeToString(mapper, items); + + assertThat(yaml.split("item:").length - 1) + .as("Should have 3 'item:' entries in the YAML") + .isEqualTo(3); + } + + @ParameterizedTest(name = "type ''{0}'' is preserved in YAML output") + @ValueSource(strings = {"add", "fix", "change", "remove"}) + @DisplayName("All valid action types are preserved in YAML output") + void changesYaml_allValidTypes_preservedInOutput(String theType) throws IOException { + ObjectMapper mapper = createChangesMapper(); + List items = buildItems(buildItemNoIssue(theType, "Some title")); + String yaml = serializeToString(mapper, items); + + // Round-trip parse — quoting-agnostic, safe for LFJT3 + List> parsed = mapper.readValue(yaml, List.class); + Map item = (Map) parsed.get(0).get("item"); + assertThat(item.get("type")).isEqualTo(theType); + } + + @Test + @DisplayName("YAML output is valid (parseable back to a list)") + void changesYaml_outputIsValidYaml() throws IOException { + ObjectMapper mapper = createChangesMapper(); + List items = buildItems( + buildItem("add", "HAPI-99", "Feature addition")); + + StringWriter sw = new StringWriter(); + mapper.writeValue(sw, items); + String yaml = sw.toString(); + + // Round-trip: parse back and verify structure + @SuppressWarnings("unchecked") + List> reparsed = mapper.readValue(yaml, List.class); + assertThat(reparsed).hasSize(1); + assertThat(reparsed.get(0)).containsKey("item"); + } + } + + + // ───────────────────────────────────────────────────────────────────────── + // 3. version.yaml STRUCTURE + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("version.yaml output structure") + class VersionYamlStructureTests { + + @Test + @DisplayName("release-date is always written") + void versionYaml_releaseDateAlwaysWritten() throws IOException { + ObjectMapper mapper = createVersionMapper(); + HashMap versionMap = new HashMap<>(); + versionMap.put("release-date", "2024-06-15"); + + String yaml = serializeToString(mapper, versionMap); + + assertThat(yaml).contains("release-date"); + // Value check via round-trip — avoids quoting sensitivity (single vs double vs bare) + Map parsed = createVersionMapper().readValue(yaml, Map.class); + assertThat(parsed.get("release-date")).isEqualTo("2024-06-15"); + } + + @Test + @DisplayName("codename written when description is present") + void versionYaml_codenameWrittenWhenDescriptionPresent() throws IOException { + ObjectMapper mapper = createVersionMapper(); + HashMap versionMap = new HashMap<>(); + versionMap.put("release-date", "2024-06-15"); + versionMap.put("codename", "Argon"); + + String yaml = serializeToString(mapper, versionMap); + + // Round-trip parse — quoting-agnostic, safe for LFJT3 + Map parsed = mapper.readValue(yaml, Map.class); + assertThat(parsed.get("codename")).isEqualTo("Argon"); + assertThat(parsed.get("release-date")).isEqualTo("2024-06-15"); + } + + @Test + @DisplayName("codename absent when description is blank (mirrors isNotBlank check)") + void versionYaml_codenameAbsentWhenDescriptionBlank() throws IOException { + ObjectMapper mapper = createVersionMapper(); + + // Replicate ChangelogMigrator's isNotBlank guard: + // if (isNotBlank(description)) { versionMap.put("codename", description); } + HashMap versionMap = new HashMap<>(); + versionMap.put("release-date", "2024-06-15"); + String description = ""; + if (description != null && !description.isBlank()) { + versionMap.put("codename", description); + } + + String yaml = serializeToString(mapper, versionMap); + + assertThat(yaml) + .as("codename key must not appear when description is blank") + .doesNotContain("codename"); + } + } + + + // ───────────────────────────────────────────────────────────────────────── + // 4. ITEM MAP BUILDING LOGIC (pure Java — zero Jackson dependency) + // Tests the logic that builds the HashMap structures fed to the mapper. + // These tests are completely Jackson-version-agnostic and need NO + // changes whatsoever during LFJT3 uplift. + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Item map building logic (Jackson-agnostic)") + class ItemMapBuildingTests { + + @Test + @DisplayName("Type 'add' mapped correctly") + void buildItem_typeAdd() { + HashMap itemMap = applyTypeMapping("add"); + assertThat(itemMap.get("type")).isEqualTo("add"); + } + + @Test + @DisplayName("Type 'fix' mapped correctly") + void buildItem_typeFix() { + HashMap itemMap = applyTypeMapping("fix"); + assertThat(itemMap.get("type")).isEqualTo("fix"); + } + + @Test + @DisplayName("Type 'change' mapped correctly") + void buildItem_typeChange() { + HashMap itemMap = applyTypeMapping("change"); + assertThat(itemMap.get("type")).isEqualTo("change"); + } + + @Test + @DisplayName("Type 'remove' mapped correctly") + void buildItem_typeRemove() { + HashMap itemMap = applyTypeMapping("remove"); + assertThat(itemMap.get("type")).isEqualTo("remove"); + } + + @Test + @DisplayName("Unknown type throws Error (mirrors Msg.code(630) branch)") + void buildItem_unknownTypeThrowsError() { + assertThrows(Error.class, () -> applyTypeMapping("unknown"), + "Unknown type must throw Error, matching ChangelogMigrator's Msg.code(630) branch"); + } + + @Test + @DisplayName("Title text is trimmed") + void buildItem_titleIsTrimmed() { + String raw = " Some title text "; + String normalized = raw.trim().replaceAll(" {2}", " "); + assertThat(normalized).isEqualTo("Some title text"); + } + + @Test + @DisplayName("Double spaces in title are collapsed to single space") + void buildItem_doubleSpacesCollapsed() { + String raw = "Some title with double spaces"; + String normalized = raw.trim().replaceAll(" {2}", " "); + assertThat(normalized).isEqualTo("Some title with double spaces"); + } + + @Test + @DisplayName("Issue key added when issue is not blank") + void buildItem_issueAddedWhenNotBlank() { + HashMap itemMap = new HashMap<>(); + String issue = "HAPI-456"; + if (issue != null && !issue.isBlank()) { + itemMap.put("issue", issue); + } + assertThat(itemMap).containsKey("issue"); + assertThat(itemMap.get("issue")).isEqualTo("HAPI-456"); + } + + @Test + @DisplayName("Issue key omitted when issue is null") + void buildItem_issueOmittedWhenNull() { + HashMap itemMap = new HashMap<>(); + String issue = null; + if (issue != null && !issue.isBlank()) { + itemMap.put("issue", issue); + } + assertThat(itemMap).doesNotContainKey("issue"); + } + + @Test + @DisplayName("Issue key omitted when issue is blank string") + void buildItem_issueOmittedWhenBlank() { + HashMap itemMap = new HashMap<>(); + String issue = ""; + if (issue != null && !issue.isBlank()) { + itemMap.put("issue", issue); + } + assertThat(itemMap).doesNotContainKey("issue"); + } + + @Test + @DisplayName("Item root map structure: outer key is 'item', inner map holds fields") + void buildItem_rootMapStructure() { + HashMap itemRootMap = new HashMap<>(); + HashMap itemMap = new HashMap<>(); + itemMap.put("type", "add"); + itemMap.put("title", "Some feature"); + itemRootMap.put("item", itemMap); + + assertThat(itemRootMap).containsKey("item"); + assertThat(itemRootMap.get("item")).isInstanceOf(Map.class); + @SuppressWarnings("unchecked") + Map inner = (Map) itemRootMap.get("item"); + assertThat(inner).containsEntry("type", "add"); + assertThat(inner).containsEntry("title", "Some feature"); + } + } + + + // ───────────────────────────────────────────────────────────────────────── + // 5. FILESYSTEM OUTPUT (integration-style, uses @TempDir) + // Verifies that the mapper actually writes files to disk as expected. + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Filesystem output") + class FilesystemOutputTests { + + @Test + @DisplayName("changes.yaml file is created and contains expected content") + void changesYaml_fileCreatedWithContent(@TempDir Path tempDir) throws IOException { + ObjectMapper mapper = createChangesMapper(); + + List items = buildItems( + buildItem("add", "HAPI-1", "A new feature"), + buildItem("fix", "HAPI-2", "A bug fix")); + + File outputFile = tempDir.resolve("changes.yaml").toFile(); + try (FileWriter writer = new FileWriter(outputFile, false)) { + mapper.writeValue(writer, items); + } + + assertTrue(outputFile.exists(), "changes.yaml must be created"); + String content = Files.readString(outputFile.toPath()); + + // Round-trip parse the file content — quoting-agnostic, safe for LFJT3 + List> parsed = mapper.readValue(content, List.class); + assertThat(parsed).hasSize(2); + + Map item0 = (Map) parsed.get(0).get("item"); + assertThat(item0.get("type")).isEqualTo("add"); + assertThat(item0.get("issue")).isEqualTo("HAPI-1"); + + Map item1 = (Map) parsed.get(1).get("item"); + assertThat(item1.get("type")).isEqualTo("fix"); + assertThat(item1.get("issue")).isEqualTo("HAPI-2"); + } + + @Test + @DisplayName("version.yaml file is created with release-date and codename") + void versionYaml_fileCreatedWithContent(@TempDir Path tempDir) throws IOException { + ObjectMapper mapper = createVersionMapper(); + + HashMap versionMap = new HashMap<>(); + versionMap.put("release-date", "2024-06-01"); + versionMap.put("codename", "Beryllium"); + + File outputFile = tempDir.resolve("version.yaml").toFile(); + try (FileWriter writer = new FileWriter(outputFile, false)) { + mapper.writeValue(writer, versionMap); + } + + assertTrue(outputFile.exists(), "version.yaml must be created"); + String content = Files.readString(outputFile.toPath()); + assertThat(content).contains("release-date"); + assertThat(content).contains("2024-06-01"); + assertThat(content).contains("codename"); + assertThat(content).contains("Beryllium"); + } + + @Test + @DisplayName("Overwrite mode: FileWriter(file, false) truncates existing content") + void fileWriter_overwriteMode_truncatesExistingContent(@TempDir Path tempDir) + throws IOException { + ObjectMapper mapper = createVersionMapper(); + + File outputFile = tempDir.resolve("version.yaml").toFile(); + + // Write first version + HashMap first = new HashMap<>(); + first.put("release-date", "2023-01-01"); + try (FileWriter writer = new FileWriter(outputFile, false)) { + mapper.writeValue(writer, first); + } + + // Overwrite with second version + HashMap second = new HashMap<>(); + second.put("release-date", "2024-06-01"); + try (FileWriter writer = new FileWriter(outputFile, false)) { + mapper.writeValue(writer, second); + } + + String content = Files.readString(outputFile.toPath()); + assertThat(content) + .as("File must contain only the second write — not both") + .doesNotContain("2023-01-01") + .contains("2024-06-01"); + } + } + + + // ───────────────────────────────────────────────────────────────────────── + // Test helpers — pure Java, no Jackson, no LFJT3 changes needed + // ───────────────────────────────────────────────────────────────────────── + + /** Replicates ChangelogMigrator's type switch block. */ + private HashMap applyTypeMapping(String theType) { + HashMap itemMap = new HashMap<>(); + switch (theType) { + case "change": itemMap.put("type", "change"); break; + case "fix": itemMap.put("type", "fix"); break; + case "remove": itemMap.put("type", "remove"); break; + case "add": itemMap.put("type", "add"); break; + default: throw new Error("Unknown type: " + theType); + } + return itemMap; + } + + private List buildItems(HashMap... theItems) { + List items = new ArrayList<>(); + for (HashMap item : theItems) { + items.add(item); + } + return items; + } + + /** Build an item root map with issue. */ + private HashMap buildItem(String theType, String theIssue, String theTitle) { + HashMap itemRootMap = new HashMap<>(); + HashMap itemMap = new HashMap<>(); + itemMap.put("type", theType); + itemMap.put("issue", theIssue); + itemMap.put("title", theTitle); + itemRootMap.put("item", itemMap); + return itemRootMap; + } + + /** Build an item root map without issue. */ + private HashMap buildItemNoIssue(String theType, String theTitle) { + HashMap itemRootMap = new HashMap<>(); + HashMap itemMap = new HashMap<>(); + itemMap.put("type", theType); + itemMap.put("title", theTitle); + itemRootMap.put("item", itemMap); + return itemRootMap; + } + + private String serializeToString(ObjectMapper theMapper, Object theValue) throws IOException { + StringWriter sw = new StringWriter(); + theMapper.writeValue(sw, theValue); + return sw.toString(); + } +} diff --git a/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/subscription/ResourceModifiedMessagePersistenceSvcImplTest.java b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/subscription/ResourceModifiedMessagePersistenceSvcImplTest.java new file mode 100644 index 000000000000..245545ec66c3 --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/subscription/ResourceModifiedMessagePersistenceSvcImplTest.java @@ -0,0 +1,7 @@ +package ca.uhn.fhir.jpa.subscription; + +import static org.junit.jupiter.api.Assertions.*; + +class ResourceModifiedMessagePersistenceSvcImplTest { + +} diff --git a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/serializer/FhirResourceSerializerTest.java b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/serializer/FhirResourceSerializerTest.java new file mode 100644 index 000000000000..4e796d6e4611 --- /dev/null +++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/serializer/FhirResourceSerializerTest.java @@ -0,0 +1,361 @@ +package ca.uhn.fhir.serializer; + +/* + * FhirResourceSerializerTest + * + * PURPOSE + * ------- + * These tests lock down the behaviour of FhirResourceSerializer under Jackson 2 + * (com.fasterxml.jackson) so that the same tests can be re-run — without + * modification to the test logic — after the Jackson 3 (tools.jackson) uplift. + * + * JACKSON 3 MIGRATION GUIDE FOR THIS FILE + * ---------------------------------------- + * When upgrading to jackson-tools-3, change ONLY the clearly marked + * "── JACKSON IMPORT BLOCK ──" section below. Test logic is untouched. + * + * Jackson 2 → Jackson 3 import swaps: + * + * com.fasterxml.jackson.databind.ObjectMapper + * → tools.jackson.databind.ObjectMapper + * + * com.fasterxml.jackson.databind.JsonNode + * → tools.jackson.databind.JsonNode + * + * com.fasterxml.jackson.databind.module.SimpleModule + * → tools.jackson.databind.module.SimpleModule + * + * com.fasterxml.jackson.databind.json.JsonMapper (Jackson 3 only — replaces new ObjectMapper()) + * → tools.jackson.databind.json.JsonMapper + * + * createMapper() helper below is the single place that constructs ObjectMapper. + * In Jackson 3, change: + * new ObjectMapper() → JsonMapper.builder().build() + * Everything else stays the same. + * + * NOTE: StdSerializer also moves: + * com.fasterxml.jackson.databind.ser.std.StdSerializer + * → tools.jackson.databind.ser.std.StdSerializer + * That change lives in FhirResourceSerializer.java itself, not here. + */ + +import ca.uhn.fhir.context.FhirContext; + +// ── JACKSON IMPORT BLOCK ───────────────────────────────────────────────────── +// ONLY this block changes during the Jackson 3 uplift. See migration guide above. +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +// ── END JACKSON IMPORT BLOCK ────────────────────────────────────────────────── + +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.r4.model.Enumerations; +import org.hl7.fhir.r4.model.Observation; +import org.hl7.fhir.r4.model.Patient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.io.StringWriter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for {@link FhirResourceSerializer}. + * + *

Tests are written against Jackson 2 ({@code com.fasterxml.jackson}) but are + * deliberately structured so that only the import block above and the + * {@link #createMapper(FhirContext)} factory method need to change when migrating + * to Jackson 3 ({@code tools.jackson}). All assertions operate on plain + * {@code String} and {@code JsonNode} so they are Jackson-version-agnostic. + */ +class FhirResourceSerializerTest { + + // ── Shared FHIR context (expensive to construct — reuse across tests) ──── + private static final FhirContext FHIR_CONTEXT = FhirContext.forR4(); + + private ObjectMapper myMapper; + + // ── JACKSON FACTORY METHOD ─────────────────────────────────────────────── + // Jackson 3 change: replace new ObjectMapper() + // with JsonMapper.builder().build() + // Everything else in this method stays the same. + private ObjectMapper createMapper(FhirContext theFhirContext) { + SimpleModule module = new SimpleModule(); + module.addSerializer(new FhirResourceSerializer(theFhirContext)); + ObjectMapper mapper = new ObjectMapper(); // Jackson 3: JsonMapper.builder().build() + mapper.registerModule(module); + return mapper; + } + // ── END JACKSON FACTORY METHOD ─────────────────────────────────────────── + + @BeforeEach + void setUp() { + myMapper = createMapper(FHIR_CONTEXT); + } + + // ───────────────────────────────────────────────────────────────────────── + // 1. OUTPUT FORMAT — the most critical behavioural contract: + // writeRawValue() must be used, not writeString(). + // If writeString() were used the FHIR JSON would appear as an escaped + // string value in the parent JSON, breaking all consumers. + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Output format — raw embedded JSON, not escaped string") + class OutputFormatTests { + + @Test + @DisplayName("FHIR resource is embedded as a JSON object, not a quoted string") + void serialize_embedsFhirResourceAsJsonObject_notQuotedString() throws Exception { + // Given + Patient patient = buildMinimalPatient(); + Wrapper wrapper = new Wrapper(patient); + + // When + String json = myMapper.writeValueAsString(wrapper); + + // Then — the value of the "resource" field must be a JSON object {…}, + // NOT a quoted string "…". If writeString() were called instead of + // writeRawValue() this assertion would fail. + JsonNode root = myMapper.readTree(json); + JsonNode resourceNode = root.get("resource"); + + assertNotNull(resourceNode, "root JSON must contain a 'resource' field"); + assertTrue(resourceNode.isObject(), + "'resource' field must be a JSON object, was: " + resourceNode.getNodeType()); + } + + @Test + @DisplayName("Serialized output is valid JSON (parseable without error)") + void serialize_producesValidJson() { + Patient patient = buildMinimalPatient(); + Wrapper wrapper = new Wrapper(patient); + + assertDoesNotThrow(() -> { + String json = myMapper.writeValueAsString(wrapper); + myMapper.readTree(json); // throws if invalid JSON + }, "Serialized output must be valid JSON"); + } + + @Test + @DisplayName("Embedded FHIR JSON contains resourceType field") + void serialize_embeddedJsonContainsResourceType() throws Exception { + Patient patient = buildMinimalPatient(); + Wrapper wrapper = new Wrapper(patient); + + String json = myMapper.writeValueAsString(wrapper); + JsonNode resourceNode = myMapper.readTree(json).get("resource"); + + assertThat(resourceNode.has("resourceType")) + .as("Embedded FHIR JSON must contain 'resourceType'") + .isTrue(); + assertThat(resourceNode.get("resourceType").asText()) + .isEqualTo("Patient"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 2. PRETTY PRINT — parser is constructed with setPrettyPrint(true) + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Pretty print — parser constructed with setPrettyPrint(true)") + class PrettyPrintTests { + + @Test + @DisplayName("Embedded FHIR JSON contains newlines (pretty-printed)") + void serialize_embeddedFhirJsonIsPrettyPrinted() throws Exception { + Patient patient = buildMinimalPatient(); + Wrapper wrapper = new Wrapper(patient); + + String json = myMapper.writeValueAsString(wrapper); + + // Extract the raw embedded FHIR fragment — it should contain newlines + // because the parser is created with setPrettyPrint(true). + // We check for \n in the overall output since the FHIR block is inlined. + assertThat(json) + .as("Serialized output should contain newlines from pretty-printed FHIR JSON") + .contains("\n"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 3. RESOURCE TYPE COVERAGE — serializer handles IBaseResource subtypes + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Resource type coverage") + class ResourceTypeCoverageTests { + + @Test + @DisplayName("Serializes Patient resource") + void serialize_patient() throws Exception { + Patient patient = buildMinimalPatient(); + assertSerializesWithResourceType(patient, "Patient"); + } + + @Test + @DisplayName("Serializes Patient with demographic fields") + void serialize_patientWithDemographics() throws Exception { + Patient patient = new Patient(); + patient.setId("patient-demo-1"); + patient.setGender(Enumerations.AdministrativeGender.FEMALE); + patient.addName().setFamily("Smith").addGiven("Jane"); + patient.addAddress().setCity("Boston").setState("MA").setPostalCode("02101"); + + String json = myMapper.writeValueAsString(new Wrapper(patient)); + JsonNode resourceNode = myMapper.readTree(json).get("resource"); + + assertThat(resourceNode.get("resourceType").asText()).isEqualTo("Patient"); + assertThat(resourceNode.has("gender")).isTrue(); + assertThat(resourceNode.get("gender").asText()).isEqualTo("female"); + assertThat(resourceNode.has("name")).isTrue(); + assertThat(resourceNode.has("address")).isTrue(); + } + + @Test + @DisplayName("Serializes Observation resource") + void serialize_observation() throws Exception { + Observation observation = new Observation(); + observation.setId("obs-1"); + observation.setStatus(Observation.ObservationStatus.FINAL); + assertSerializesWithResourceType(observation, "Observation"); + } + + @Test + @DisplayName("Serializes resource with ID preserved") + void serialize_preservesResourceId() throws Exception { + Patient patient = new Patient(); + patient.setId("test-patient-id-999"); + + String json = myMapper.writeValueAsString(new Wrapper(patient)); + JsonNode resourceNode = myMapper.readTree(json).get("resource"); + + assertThat(resourceNode.has("id")).isTrue(); + assertThat(resourceNode.get("id").asText()).isEqualTo("test-patient-id-999"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 4. DIRECT SERIALIZATION — serialize the resource as the root value + // (not wrapped in an outer object) + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Direct serialization — resource as root value") + class DirectSerializationTests { + + @Test + @DisplayName("Resource serialized directly to String is valid JSON") + void serialize_directlyToString_isValidJson() throws Exception { + Patient patient = buildMinimalPatient(); + + // Serialize directly (no wrapper object) + StringWriter writer = new StringWriter(); + myMapper.writeValue(writer, patient); + String json = writer.toString(); + + assertDoesNotThrow(() -> myMapper.readTree(json), + "Direct serialization output must be valid JSON"); + } + + @Test + @DisplayName("Resource serialized directly contains resourceType") + void serialize_directlyToString_containsResourceType() throws Exception { + Patient patient = buildMinimalPatient(); + + StringWriter writer = new StringWriter(); + myMapper.writeValue(writer, patient); + + JsonNode root = myMapper.readTree(writer.toString()); + assertThat(root.get("resourceType").asText()).isEqualTo("Patient"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 5. ROUND-TRIP — serialize then re-parse the embedded FHIR JSON + // back into a FHIR resource and verify field integrity + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Round-trip — serialize → re-parse embedded FHIR JSON") + class RoundTripTests { + + @Test + @DisplayName("Round-trip Patient preserves family name") + void roundTrip_patientFamilyName() throws Exception { + Patient original = new Patient(); + original.setId("rt-patient-1"); + original.addName().setFamily("Johnson").addGiven("Robert"); + + // Serialize + String json = myMapper.writeValueAsString(new Wrapper(original)); + JsonNode resourceNode = myMapper.readTree(json).get("resource"); + + // Re-parse the embedded FHIR JSON back into a Patient + String embeddedFhirJson = myMapper.writeValueAsString(resourceNode); + Patient reparsed = (Patient) FHIR_CONTEXT.newJsonParser() + .parseResource(embeddedFhirJson); + + assertThat(reparsed.getNameFirstRep().getFamily()) + .isEqualTo("Johnson"); + assertThat(reparsed.getNameFirstRep().getGivenAsSingleString()) + .isEqualTo("Robert"); + } + + @Test + @DisplayName("Round-trip Patient preserves gender") + void roundTrip_patientGender() throws Exception { + Patient original = new Patient(); + original.setId("rt-patient-2"); + original.setGender(Enumerations.AdministrativeGender.MALE); + + String json = myMapper.writeValueAsString(new Wrapper(original)); + JsonNode resourceNode = myMapper.readTree(json).get("resource"); + + String embeddedFhirJson = myMapper.writeValueAsString(resourceNode); + Patient reparsed = (Patient) FHIR_CONTEXT.newJsonParser() + .parseResource(embeddedFhirJson); + + assertThat(reparsed.getGender()) + .isEqualTo(Enumerations.AdministrativeGender.MALE); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + private static Patient buildMinimalPatient() { + Patient patient = new Patient(); + patient.setId("minimal-patient-1"); + return patient; + } + + private void assertSerializesWithResourceType(IBaseResource theResource, String theExpectedType) + throws Exception { + String json = myMapper.writeValueAsString(new Wrapper(theResource)); + JsonNode resourceNode = myMapper.readTree(json).get("resource"); + + assertNotNull(resourceNode, "root JSON must contain a 'resource' field"); + assertTrue(resourceNode.isObject(), "'resource' must be a JSON object"); + assertThat(resourceNode.get("resourceType").asText()).isEqualTo(theExpectedType); + } + + /** + * Simple wrapper to produce an outer JSON object with a single "resource" + * field — this lets us verify that the serializer writes a JSON object + * value rather than a JSON string value (i.e. writeRawValue vs writeString). + */ + static class Wrapper { + private final IBaseResource myResource; + + Wrapper(IBaseResource theResource) { + myResource = theResource; + } + + public IBaseResource getResource() { + return myResource; + } + } +} diff --git a/hapi-fhir-structures-r5/src/test/java/ca/uhn/fhir/serializer/FhirResourceSerializerTest.java b/hapi-fhir-structures-r5/src/test/java/ca/uhn/fhir/serializer/FhirResourceSerializerTest.java new file mode 100644 index 000000000000..8ea7bc706627 --- /dev/null +++ b/hapi-fhir-structures-r5/src/test/java/ca/uhn/fhir/serializer/FhirResourceSerializerTest.java @@ -0,0 +1,361 @@ +package ca.uhn.fhir.serializer; + +/* + * FhirResourceSerializerTest + * + * PURPOSE + * ------- + * These tests lock down the behaviour of FhirResourceSerializer under Jackson 2 + * (com.fasterxml.jackson) so that the same tests can be re-run — without + * modification to the test logic — after the Jackson 3 (tools.jackson) uplift. + * + * JACKSON 3 MIGRATION GUIDE FOR THIS FILE + * ---------------------------------------- + * When upgrading to jackson-tools-3, change ONLY the clearly marked + * "── JACKSON IMPORT BLOCK ──" section below. Test logic is untouched. + * + * Jackson 2 → Jackson 3 import swaps: + * + * com.fasterxml.jackson.databind.ObjectMapper + * → tools.jackson.databind.ObjectMapper + * + * com.fasterxml.jackson.databind.JsonNode + * → tools.jackson.databind.JsonNode + * + * com.fasterxml.jackson.databind.module.SimpleModule + * → tools.jackson.databind.module.SimpleModule + * + * com.fasterxml.jackson.databind.json.JsonMapper (Jackson 3 only — replaces new ObjectMapper()) + * → tools.jackson.databind.json.JsonMapper + * + * createMapper() helper below is the single place that constructs ObjectMapper. + * In Jackson 3, change: + * new ObjectMapper() → JsonMapper.builder().build() + * Everything else stays the same. + * + * NOTE: StdSerializer also moves: + * com.fasterxml.jackson.databind.ser.std.StdSerializer + * → tools.jackson.databind.ser.std.StdSerializer + * That change lives in FhirResourceSerializer.java itself, not here. + */ + +import ca.uhn.fhir.context.FhirContext; + +// ── JACKSON IMPORT BLOCK ───────────────────────────────────────────────────── +// ONLY this block changes during the Jackson 3 uplift. See migration guide above. +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +// ── END JACKSON IMPORT BLOCK ────────────────────────────────────────────────── + +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.r5.model.Enumerations; +import org.hl7.fhir.r5.model.Observation; +import org.hl7.fhir.r5.model.Patient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.io.StringWriter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for {@link FhirResourceSerializer}. + * + *

Tests are written against Jackson 2 ({@code com.fasterxml.jackson}) but are + * deliberately structured so that only the import block above and the + * {@link #createMapper(FhirContext)} factory method need to change when migrating + * to Jackson 3 ({@code tools.jackson}). All assertions operate on plain + * {@code String} and {@code JsonNode} so they are Jackson-version-agnostic. + */ +class FhirResourceSerializerTest { + + // ── Shared FHIR context (expensive to construct — reuse across tests) ──── + private static final FhirContext FHIR_CONTEXT = FhirContext.forR5(); + + private ObjectMapper myMapper; + + // ── JACKSON FACTORY METHOD ─────────────────────────────────────────────── + // Jackson 3 change: replace new ObjectMapper() + // with JsonMapper.builder().build() + // Everything else in this method stays the same. + private ObjectMapper createMapper(FhirContext theFhirContext) { + SimpleModule module = new SimpleModule(); + module.addSerializer(new FhirResourceSerializer(theFhirContext)); + ObjectMapper mapper = new ObjectMapper(); // Jackson 3: JsonMapper.builder().build() + mapper.registerModule(module); + return mapper; + } + // ── END JACKSON FACTORY METHOD ─────────────────────────────────────────── + + @BeforeEach + void setUp() { + myMapper = createMapper(FHIR_CONTEXT); + } + + // ───────────────────────────────────────────────────────────────────────── + // 1. OUTPUT FORMAT — the most critical behavioural contract: + // writeRawValue() must be used, not writeString(). + // If writeString() were used the FHIR JSON would appear as an escaped + // string value in the parent JSON, breaking all consumers. + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Output format — raw embedded JSON, not escaped string") + class OutputFormatTests { + + @Test + @DisplayName("FHIR resource is embedded as a JSON object, not a quoted string") + void serialize_embedsFhirResourceAsJsonObject_notQuotedString() throws Exception { + // Given + Patient patient = buildMinimalPatient(); + Wrapper wrapper = new Wrapper(patient); + + // When + String json = myMapper.writeValueAsString(wrapper); + + // Then — the value of the "resource" field must be a JSON object {…}, + // NOT a quoted string "…". If writeString() were called instead of + // writeRawValue() this assertion would fail. + JsonNode root = myMapper.readTree(json); + JsonNode resourceNode = root.get("resource"); + + assertNotNull(resourceNode, "root JSON must contain a 'resource' field"); + assertTrue(resourceNode.isObject(), + "'resource' field must be a JSON object, was: " + resourceNode.getNodeType()); + } + + @Test + @DisplayName("Serialized output is valid JSON (parseable without error)") + void serialize_producesValidJson() { + Patient patient = buildMinimalPatient(); + Wrapper wrapper = new Wrapper(patient); + + assertDoesNotThrow(() -> { + String json = myMapper.writeValueAsString(wrapper); + myMapper.readTree(json); // throws if invalid JSON + }, "Serialized output must be valid JSON"); + } + + @Test + @DisplayName("Embedded FHIR JSON contains resourceType field") + void serialize_embeddedJsonContainsResourceType() throws Exception { + Patient patient = buildMinimalPatient(); + Wrapper wrapper = new Wrapper(patient); + + String json = myMapper.writeValueAsString(wrapper); + JsonNode resourceNode = myMapper.readTree(json).get("resource"); + + assertThat(resourceNode.has("resourceType")) + .as("Embedded FHIR JSON must contain 'resourceType'") + .isTrue(); + assertThat(resourceNode.get("resourceType").asText()) + .isEqualTo("Patient"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 2. PRETTY PRINT — parser is constructed with setPrettyPrint(true) + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Pretty print — parser constructed with setPrettyPrint(true)") + class PrettyPrintTests { + + @Test + @DisplayName("Embedded FHIR JSON contains newlines (pretty-printed)") + void serialize_embeddedFhirJsonIsPrettyPrinted() throws Exception { + Patient patient = buildMinimalPatient(); + Wrapper wrapper = new Wrapper(patient); + + String json = myMapper.writeValueAsString(wrapper); + + // Extract the raw embedded FHIR fragment — it should contain newlines + // because the parser is created with setPrettyPrint(true). + // We check for \n in the overall output since the FHIR block is inlined. + assertThat(json) + .as("Serialized output should contain newlines from pretty-printed FHIR JSON") + .contains("\n"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 3. RESOURCE TYPE COVERAGE — serializer handles IBaseResource subtypes + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Resource type coverage") + class ResourceTypeCoverageTests { + + @Test + @DisplayName("Serializes Patient resource") + void serialize_patient() throws Exception { + Patient patient = buildMinimalPatient(); + assertSerializesWithResourceType(patient, "Patient"); + } + + @Test + @DisplayName("Serializes Patient with demographic fields") + void serialize_patientWithDemographics() throws Exception { + Patient patient = new Patient(); + patient.setId("patient-demo-1"); + patient.setGender(Enumerations.AdministrativeGender.FEMALE); + patient.addName().setFamily("Smith").addGiven("Jane"); + patient.addAddress().setCity("Boston").setState("MA").setPostalCode("02101"); + + String json = myMapper.writeValueAsString(new Wrapper(patient)); + JsonNode resourceNode = myMapper.readTree(json).get("resource"); + + assertThat(resourceNode.get("resourceType").asText()).isEqualTo("Patient"); + assertThat(resourceNode.has("gender")).isTrue(); + assertThat(resourceNode.get("gender").asText()).isEqualTo("female"); + assertThat(resourceNode.has("name")).isTrue(); + assertThat(resourceNode.has("address")).isTrue(); + } + + @Test + @DisplayName("Serializes Observation resource") + void serialize_observation() throws Exception { + Observation observation = new Observation(); + observation.setId("obs-1"); + observation.setStatus(Enumerations.ObservationStatus.FINAL); + assertSerializesWithResourceType(observation, "Observation"); + } + + @Test + @DisplayName("Serializes resource with ID preserved") + void serialize_preservesResourceId() throws Exception { + Patient patient = new Patient(); + patient.setId("test-patient-id-999"); + + String json = myMapper.writeValueAsString(new Wrapper(patient)); + JsonNode resourceNode = myMapper.readTree(json).get("resource"); + + assertThat(resourceNode.has("id")).isTrue(); + assertThat(resourceNode.get("id").asText()).isEqualTo("test-patient-id-999"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 4. DIRECT SERIALIZATION — serialize the resource as the root value + // (not wrapped in an outer object) + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Direct serialization — resource as root value") + class DirectSerializationTests { + + @Test + @DisplayName("Resource serialized directly to String is valid JSON") + void serialize_directlyToString_isValidJson() throws Exception { + Patient patient = buildMinimalPatient(); + + // Serialize directly (no wrapper object) + StringWriter writer = new StringWriter(); + myMapper.writeValue(writer, patient); + String json = writer.toString(); + + assertDoesNotThrow(() -> myMapper.readTree(json), + "Direct serialization output must be valid JSON"); + } + + @Test + @DisplayName("Resource serialized directly contains resourceType") + void serialize_directlyToString_containsResourceType() throws Exception { + Patient patient = buildMinimalPatient(); + + StringWriter writer = new StringWriter(); + myMapper.writeValue(writer, patient); + + JsonNode root = myMapper.readTree(writer.toString()); + assertThat(root.get("resourceType").asText()).isEqualTo("Patient"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 5. ROUND-TRIP — serialize then re-parse the embedded FHIR JSON + // back into a FHIR resource and verify field integrity + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Round-trip — serialize → re-parse embedded FHIR JSON") + class RoundTripTests { + + @Test + @DisplayName("Round-trip Patient preserves family name") + void roundTrip_patientFamilyName() throws Exception { + Patient original = new Patient(); + original.setId("rt-patient-1"); + original.addName().setFamily("Johnson").addGiven("Robert"); + + // Serialize + String json = myMapper.writeValueAsString(new Wrapper(original)); + JsonNode resourceNode = myMapper.readTree(json).get("resource"); + + // Re-parse the embedded FHIR JSON back into a Patient + String embeddedFhirJson = myMapper.writeValueAsString(resourceNode); + Patient reparsed = (Patient) FHIR_CONTEXT.newJsonParser() + .parseResource(embeddedFhirJson); + + assertThat(reparsed.getNameFirstRep().getFamily()) + .isEqualTo("Johnson"); + assertThat(reparsed.getNameFirstRep().getGivenAsSingleString()) + .isEqualTo("Robert"); + } + + @Test + @DisplayName("Round-trip Patient preserves gender") + void roundTrip_patientGender() throws Exception { + Patient original = new Patient(); + original.setId("rt-patient-2"); + original.setGender(Enumerations.AdministrativeGender.MALE); + + String json = myMapper.writeValueAsString(new Wrapper(original)); + JsonNode resourceNode = myMapper.readTree(json).get("resource"); + + String embeddedFhirJson = myMapper.writeValueAsString(resourceNode); + Patient reparsed = (Patient) FHIR_CONTEXT.newJsonParser() + .parseResource(embeddedFhirJson); + + assertThat(reparsed.getGender()) + .isEqualTo(Enumerations.AdministrativeGender.MALE); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + private static Patient buildMinimalPatient() { + Patient patient = new Patient(); + patient.setId("minimal-patient-1"); + return patient; + } + + private void assertSerializesWithResourceType(IBaseResource theResource, String theExpectedType) + throws Exception { + String json = myMapper.writeValueAsString(new Wrapper(theResource)); + JsonNode resourceNode = myMapper.readTree(json).get("resource"); + + assertNotNull(resourceNode, "root JSON must contain a 'resource' field"); + assertTrue(resourceNode.isObject(), "'resource' must be a JSON object"); + assertThat(resourceNode.get("resourceType").asText()).isEqualTo(theExpectedType); + } + + /** + * Simple wrapper to produce an outer JSON object with a single "resource" + * field — this lets us verify that the serializer writes a JSON object + * value rather than a JSON string value (i.e. writeRawValue vs writeString). + */ + static class Wrapper { + private final IBaseResource myResource; + + Wrapper(IBaseResource theResource) { + myResource = theResource; + } + + public IBaseResource getResource() { + return myResource; + } + } +} From 2e080efb6c4face4b0ce079970008d4c63928ac1 Mon Sep 17 00:00:00 2001 From: granadacoder Date: Sat, 27 Jun 2026 18:39:33 -0400 Subject: [PATCH 4/7] Remove empty file. Will re-add later. --- .../ResourceModifiedMessagePersistenceSvcImplTest.java | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/subscription/ResourceModifiedMessagePersistenceSvcImplTest.java diff --git a/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/subscription/ResourceModifiedMessagePersistenceSvcImplTest.java b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/subscription/ResourceModifiedMessagePersistenceSvcImplTest.java deleted file mode 100644 index 245545ec66c3..000000000000 --- a/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/subscription/ResourceModifiedMessagePersistenceSvcImplTest.java +++ /dev/null @@ -1,7 +0,0 @@ -package ca.uhn.fhir.jpa.subscription; - -import static org.junit.jupiter.api.Assertions.*; - -class ResourceModifiedMessagePersistenceSvcImplTest { - -} From ee42cf32d390567ebecb805c3be5b911c4ac11ee Mon Sep 17 00:00:00 2001 From: granadacoder Date: Sat, 27 Jun 2026 18:46:27 -0400 Subject: [PATCH 5/7] Update comments to point to "r?" (as in "r4", "r5", etc. --- hapi-fhir-base/src/test/java/WHERE_ARE_THE_TESTS.txt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/hapi-fhir-base/src/test/java/WHERE_ARE_THE_TESTS.txt b/hapi-fhir-base/src/test/java/WHERE_ARE_THE_TESTS.txt index cb26de1a1329..054fb6d90ff6 100644 --- a/hapi-fhir-base/src/test/java/WHERE_ARE_THE_TESTS.txt +++ b/hapi-fhir-base/src/test/java/WHERE_ARE_THE_TESTS.txt @@ -1,5 +1,11 @@ As of HAPI 0.8 - -Tests have mostly moved to the hapi-fhir-structures-dstu subproject, +Tests have mostly moved to the: + +hapi-fhir-structures-dstu +or +hapi-fhir-structures-r? + +subproject(s), since they depend on that project. There are also tests in other -structures subprojects. \ No newline at end of file +structures subprojects. From 3e28e1bdad7311c98297b6d6fb790a58a96378cd Mon Sep 17 00:00:00 2001 From: granadacoder Date: Sun, 28 Jun 2026 23:37:33 -0400 Subject: [PATCH 6/7] Add more unit-tests in anticipation of future (JT3) uplift. --- ...dMessagePersistenceSvcImplJacksonTest.java | 256 ++++++++++++++ .../util/jsonpatch/JsonPatchUtilsTest.java | 189 +++++++++- .../svc/CdsHooksContextBooterJacksonTest.java | 177 ++++++++++ ...uestContextSerializerDeserializerTest.java | 309 ++++++++++++++++ .../fhir/cdshooks/svc/CdsSvcJacksonTest.java | 317 +++++++++++++++++ .../JsonDateSerializerDeserializerTest.java | 257 ++++++++++++++ .../jpa/binary/api/StoredDetailsTest.java | 331 ++++++++++++++++++ 7 files changed, 1834 insertions(+), 2 deletions(-) create mode 100644 hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/subscription/ResourceModifiedMessagePersistenceSvcImplJacksonTest.java create mode 100644 hapi-fhir-server-cds-hooks/src/test/java/ca/uhn/hapi/fhir/cdshooks/svc/CdsHooksContextBooterJacksonTest.java create mode 100644 hapi-fhir-server-cds-hooks/src/test/java/ca/uhn/hapi/fhir/cdshooks/svc/CdsServiceRequestContextSerializerDeserializerTest.java create mode 100644 hapi-fhir-server-cds-hooks/src/test/java/ca/uhn/hapi/fhir/cdshooks/svc/CdsSvcJacksonTest.java create mode 100644 hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/util/JsonDateSerializerDeserializerTest.java create mode 100644 hapi-fhir-storage/src/test/java/ca/uhn/fhir/jpa/binary/api/StoredDetailsTest.java diff --git a/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/subscription/ResourceModifiedMessagePersistenceSvcImplJacksonTest.java b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/subscription/ResourceModifiedMessagePersistenceSvcImplJacksonTest.java new file mode 100644 index 000000000000..ebadc11d5f70 --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/subscription/ResourceModifiedMessagePersistenceSvcImplJacksonTest.java @@ -0,0 +1,256 @@ +package ca.uhn.fhir.jpa.subscription; + +/* + * LFJT3 — "Looking Forward to Jackson Tools 3" + * ============================================= + * Written for Jackson 2 (com.fasterxml.jackson). Only the import block + * and createMapper() factory method change during the future LFJT3 uplift. + * + * LFJT3 MIGRATION CHECKLIST + * -------------------------- + * [ ] Imports: com.fasterxml.jackson.* → tools.jackson.* + * [ ] createMapper(): new ObjectMapper() → JsonMapper.builder().build() + * + * KEY BEHAVIORS LOCKED DOWN + * ------------------------- + * ResourceModifiedMessagePersistenceSvcImpl uses new ObjectMapper() internally + * (in the constructor) to: + * 1. getPayloadLessMessageAsString() — serializes ResourceModifiedMessage + * 2. getPayloadLessMessageFromString() — deserializes ResourceModifiedMessage + * + * These tests verify that behavior via the package-private createEntityFrom() + * and createResourceModifiedMessageFromEntityWithoutInflation() methods, + * which exercise the Jackson round-trip path without needing JPA/DB. + * + * In LFJT3: + * - new ObjectMapper() → JsonMapper.builder().build() + * - JsonProcessingException → JacksonException (unchecked, production class change) + * - catch block types change in production class, not here + */ + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.jpa.dao.data.IResourceModifiedDao; +import ca.uhn.fhir.jpa.dao.tx.HapiTransactionService; +import ca.uhn.fhir.jpa.model.entity.ResourceModifiedEntity; +import ca.uhn.fhir.jpa.subscription.model.ResourceModifiedMessage; +import ca.uhn.fhir.jpa.api.dao.DaoRegistry; +import ca.uhn.fhir.rest.server.messaging.BaseResourceModifiedMessage; +// ── LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────────── +// Jackson 2 (NOW): +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +// Jackson 3 (LFJT3): +// import tools.jackson.databind.JsonNode; +// import tools.jackson.databind.ObjectMapper; +// ── END LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────── +import org.hl7.fhir.r4.model.IdType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * LFJT3-aware tests for Jackson-specific behavior in + * {@link ResourceModifiedMessagePersistenceSvcImpl}. + * + *

These tests bypass JPA/DB by calling package-private methods directly, + * isolating the Jackson serialization behavior. + */ +@ExtendWith(MockitoExtension.class) +class ResourceModifiedMessagePersistenceSvcImplJacksonTest { + + private static final FhirContext FHIR_CONTEXT = FhirContext.forR4(); + + @Mock + private IResourceModifiedDao myResourceModifiedDao; + + @Mock + private DaoRegistry myDaoRegistry; + + @Mock + private HapiTransactionService myHapiTransactionService; + + private ResourceModifiedMessagePersistenceSvcImpl mySvc; + + // ── LFJT3 MAPPER FACTORY ───────────────────────────────────────────────── + // Not used directly here (the service creates its own mapper internally), + // but retained to document the LFJT3 change point. + // When LFJT3 lands: ResourceModifiedMessagePersistenceSvcImpl constructor + // myObjectMapper = new ObjectMapper() → JsonMapper.builder().build() + // Jackson 2 (NOW): new ObjectMapper() + // Jackson 3 (LFJT3): JsonMapper.builder().build() + private ObjectMapper createVerificationMapper() { + return new ObjectMapper(); + // LFJT3: return JsonMapper.builder().build(); + } + // ── END LFJT3 MAPPER FACTORY ───────────────────────────────────────────── + + @BeforeEach + void setUp() { + mySvc = new ResourceModifiedMessagePersistenceSvcImpl( + FHIR_CONTEXT, + myResourceModifiedDao, + myDaoRegistry, + myHapiTransactionService); + } + + private ResourceModifiedMessage buildMessage(String resourceType, String id, String version) { + ResourceModifiedMessage msg = new ResourceModifiedMessage(); + msg.setPayloadId(new IdType(resourceType, id, version)); + msg.setOperationType(BaseResourceModifiedMessage.OperationTypeEnum.CREATE); + return msg; + } + + // ───────────────────────────────────────────────────────────────────────── + // 1. createEntityFrom — Jackson serialization path + // ResourceModifiedMessage → JSON string → stored in entity + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("createEntityFrom — Jackson serialization") + class CreateEntityFromTests { + + @Test + @DisplayName("Entity is created without exception (Jackson serializes successfully)") + void createEntityFrom_noException() { + ResourceModifiedMessage msg = buildMessage("Patient", "pt-001", "1"); + assertDoesNotThrow(() -> mySvc.createEntityFrom(msg)); + } + + @Test + @DisplayName("Entity summary message is non-null (JSON was produced)") + void createEntityFrom_summaryMessageIsNonNull() { + ResourceModifiedMessage msg = buildMessage("Patient", "pt-002", "1"); + ResourceModifiedEntity entity = mySvc.createEntityFrom(msg); + assertNotNull(entity.getSummaryResourceModifiedMessage()); + } + + @Test + @DisplayName("Entity summary message is valid JSON") + void createEntityFrom_summaryMessageIsValidJson() throws Exception { + ResourceModifiedMessage msg = buildMessage("Patient", "pt-003", "2"); + ResourceModifiedEntity entity = mySvc.createEntityFrom(msg); + + String summary = entity.getSummaryResourceModifiedMessage(); + // Verify Jackson produced parseable JSON + ObjectMapper verifier = createVerificationMapper(); + JsonNode parsed = assertDoesNotThrow(() -> verifier.readTree(summary)); + assertNotNull(parsed); + } + + @Test + @DisplayName("Entity PK resourceType matches the message resource type") + void createEntityFrom_pkResourceType_matchesMessage() { + ResourceModifiedMessage msg = buildMessage("Observation", "obs-001", "1"); + ResourceModifiedEntity entity = mySvc.createEntityFrom(msg); + + assertThat(entity.getResourceModifiedEntityPK().getResourceType()).isEqualTo("Observation"); + } + + @Test + @DisplayName("Entity PK resourcePid matches the message ID") + void createEntityFrom_pkResourcePid_matchesMessageId() { + ResourceModifiedMessage msg = buildMessage("Patient", "pid-999", "3"); + ResourceModifiedEntity entity = mySvc.createEntityFrom(msg); + + assertThat(entity.getResourceModifiedEntityPK().getResourcePid()).isEqualTo("pid-999"); + } + + @Test + @DisplayName("Summary JSON does NOT contain the payload resource body (payload-less)") + void createEntityFrom_summaryMessageIsPayloadLess() throws Exception { + ResourceModifiedMessage msg = buildMessage("Patient", "pt-payload", "1"); + ResourceModifiedEntity entity = mySvc.createEntityFrom(msg); + + String summary = entity.getSummaryResourceModifiedMessage(); + // The PayloadLessResourceModifiedMessage strips the resource body. + // Payload-less means no "payload" or "resource" field with actual FHIR content. + // The summary should be a compact JSON metadata envelope. + ObjectMapper verifier = createVerificationMapper(); + JsonNode parsed = verifier.readTree(summary); + // The key assertion: it should NOT be deeply nested FHIR resource JSON + assertThat(summary.length()) + .as("Payload-less message should be small — not a full FHIR resource") + .isLessThan(2000); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 2. createResourceModifiedMessageFromEntityWithoutInflation + // JSON string → ResourceModifiedMessage (Jackson deserialization path) + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("createResourceModifiedMessageFromEntityWithoutInflation — Jackson deserialization") + class DeserializationTests { + + private ResourceModifiedEntity buildEntity(ResourceModifiedMessage theMsg) { + return mySvc.createEntityFrom(theMsg); + } + + @Test + @DisplayName("Deserialization does not throw (Jackson reads successfully)") + void deserialize_noException() { + ResourceModifiedMessage original = buildMessage("Patient", "pt-d1", "1"); + ResourceModifiedEntity entity = buildEntity(original); + + assertDoesNotThrow(() -> + mySvc.createResourceModifiedMessageFromEntityWithoutInflation(entity)); + } + + @Test + @DisplayName("Deserialized message is not null") + void deserialize_returnsNonNull() { + ResourceModifiedMessage original = buildMessage("Patient", "pt-d2", "1"); + ResourceModifiedEntity entity = buildEntity(original); + + ResourceModifiedMessage result = + mySvc.createResourceModifiedMessageFromEntityWithoutInflation(entity); + + assertNotNull(result); + } + + @Test + @DisplayName("Round-trip preserves operationType") + void roundTrip_operationType() { + ResourceModifiedMessage original = buildMessage("Observation", "obs-rt", "2"); + original.setOperationType(BaseResourceModifiedMessage.OperationTypeEnum.UPDATE); + ResourceModifiedEntity entity = buildEntity(original); + + ResourceModifiedMessage result = + mySvc.createResourceModifiedMessageFromEntityWithoutInflation(entity); + + assertThat(result.getOperationType()) + .isEqualTo(BaseResourceModifiedMessage.OperationTypeEnum.UPDATE); + } + + @Test + @DisplayName("Round-trip preserves resource version") + void roundTrip_resourceVersion() { + ResourceModifiedMessage original = buildMessage("Patient", "pt-ver", "7"); + ResourceModifiedEntity entity = buildEntity(original); + + ResourceModifiedMessage result = + mySvc.createResourceModifiedMessageFromEntityWithoutInflation(entity); + + assertThat(result.getPayloadVersion()).isEqualTo("7"); + } + + @Test + @DisplayName("Round-trip preserves resource ID") + void roundTrip_resourceId() { + ResourceModifiedMessage original = buildMessage("Patient", "pt-id-rt", "1"); + ResourceModifiedEntity entity = buildEntity(original); + + ResourceModifiedMessage result = + mySvc.createResourceModifiedMessageFromEntityWithoutInflation(entity); + + assertThat(result.getPayloadId()).isEqualTo("pt-id-rt"); + } + } +} diff --git a/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/util/jsonpatch/JsonPatchUtilsTest.java b/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/util/jsonpatch/JsonPatchUtilsTest.java index 32d6653cb405..b25d5c3e0c3c 100644 --- a/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/util/jsonpatch/JsonPatchUtilsTest.java +++ b/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/util/jsonpatch/JsonPatchUtilsTest.java @@ -1,18 +1,26 @@ package ca.uhn.fhir.jpa.util.jsonpatch; -import static org.junit.jupiter.api.Assertions.assertEquals; +import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.i18n.Msg; import ca.uhn.fhir.jpa.patch.JsonPatchUtils; import ca.uhn.fhir.jpa.test.BaseJpaR4Test; import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; -import org.hl7.fhir.r4.model.Observation; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import org.hl7.fhir.r4.model.Group; +import org.hl7.fhir.r4.model.Observation; +import org.hl7.fhir.r4.model.Patient; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.fail; public class JsonPatchUtilsTest extends BaseJpaR4Test { @@ -180,4 +188,181 @@ public void testPatchAddInvalidElement() { } } + + private static final FhirContext FHIR_CONTEXT = FhirContext.forR4(); + + // ── LFJT3 MAPPER FACTORY ───────────────────────────────────────────────── + // Jackson 2 (NOW): new ObjectMapper() + // Jackson 3 (LFJT3): JsonMapper.builder().build() + private ObjectMapper createMapper() { + return new ObjectMapper(); + // LFJT3: return JsonMapper.builder().build(); + } + // ── END LFJT3 MAPPER FACTORY ───────────────────────────────────────────── + + /** Build a simple Patient JSON string via HAPI. */ + private String buildPatientJson(String id, String familyName) { + Patient patient = new Patient(); + patient.setId(id); + if (familyName != null) { + patient.addName().setFamily(familyName); + } + return FHIR_CONTEXT.newJsonParser().encodeResourceToString(patient); + } + + private String applyPatchToPatientJson(String theOriginalJson, String thePatchBody) { + Patient originalResource = FHIR_CONTEXT.newJsonParser().parseResource(Patient.class, theOriginalJson); + Patient patchedResource = JsonPatchUtils.apply(FHIR_CONTEXT, originalResource, thePatchBody); + return FHIR_CONTEXT.newJsonParser().encodeResourceToString(patchedResource); + } + + // ───────────────────────────────────────────────────────────────────────── + // 1. Valid patch operations + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Valid RFC 6902 patch operations") + class ValidPatchTests { + + @Test + @DisplayName("'replace' operation changes a field value") + void patch_replaceOperation_changesField() throws Exception { + String originalJson = buildPatientJson("pt-001", "Smith"); + + // RFC 6902 replace patch + String patchBody = "[{\"op\":\"replace\",\"path\":\"/name/0/family\",\"value\":\"Jones\"}]"; + + String result = applyPatchToPatientJson(originalJson, patchBody); + + ObjectMapper mapper = createMapper(); + JsonNode resultNode = mapper.readTree(result); + assertThat(resultNode.path("name").path(0).path("family").asText()) + .isEqualTo("Jones"); + } + + @Test + @DisplayName("'add' operation inserts a new field") + void patch_addOperation_insertsField() throws Exception { + String originalJson = buildPatientJson("pt-002", null); + + // Add a gender field + String patchBody = "[{\"op\":\"add\",\"path\":\"/gender\",\"value\":\"male\"}]"; + + String result = applyPatchToPatientJson(originalJson, patchBody); + + ObjectMapper mapper = createMapper(); + JsonNode resultNode = mapper.readTree(result); + assertThat(resultNode.has("gender")).isTrue(); + assertThat(resultNode.get("gender").asText()).isEqualTo("male"); + } + + @Test + @DisplayName("Empty patch array returns original document unchanged") + void patch_emptyPatch_returnsOriginal() throws Exception { + String originalJson = buildPatientJson("pt-003", "Brown"); + String patchBody = "[]"; + + String result = applyPatchToPatientJson(originalJson, patchBody); + + ObjectMapper mapper = createMapper(); + JsonNode original = mapper.readTree(originalJson); + JsonNode patched = mapper.readTree(result); + assertThat(patched.get("resourceType").asText()) + .isEqualTo(original.get("resourceType").asText()); + } + + @Test + @DisplayName("Result is valid JSON") + void patch_result_isValidJson() throws Exception { + String originalJson = buildPatientJson("pt-004", "Taylor"); + String patchBody = "[{\"op\":\"replace\",\"path\":\"/id\",\"value\":\"pt-004-patched\"}]"; + + String result = applyPatchToPatientJson(originalJson, patchBody); + + // Verify result is parseable JSON + ObjectMapper mapper = createMapper(); + JsonNode node = mapper.readTree(result); + assertNotNull(node); + } + + @Test + @DisplayName("Result preserves resourceType field") + void patch_result_preservesResourceType() throws Exception { + String originalJson = buildPatientJson("pt-005", "White"); + String patchBody = "[{\"op\":\"replace\",\"path\":\"/id\",\"value\":\"pt-005-new\"}]"; + + String result = applyPatchToPatientJson(originalJson, patchBody); + + ObjectMapper mapper = createMapper(); + assertThat(mapper.readTree(result).get("resourceType").asText()) + .isEqualTo("Patient"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 2. Invalid patch input + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Invalid patch input") + class InvalidPatchTests { + + @Test + @DisplayName("Malformed JSON patch body throws InvalidRequestException") + void patch_malformedJson_throwsInvalidRequestException() { + String originalJson = buildPatientJson("pt-bad", "Black"); + String badPatch = "this is not json"; + + assertThrows(InvalidRequestException.class, + () -> applyPatchToPatientJson(originalJson, badPatch)); + } + + @Test + @DisplayName("Patch with invalid op throws InvalidRequestException") + void patch_invalidOp_throwsInvalidRequestException() { + String originalJson = buildPatientJson("pt-bad2", "Green"); + String badPatch = "[{\"op\":\"invalidop\",\"path\":\"/id\",\"value\":\"x\"}]"; + + assertThrows(InvalidRequestException.class, + () -> applyPatchToPatientJson(originalJson, badPatch)); + } + + @Test + @DisplayName("Patch pointing to non-existent path throws InvalidRequestException") + void patch_nonExistentPath_throwsInvalidRequestException() { + String originalJson = buildPatientJson("pt-bad3", null); + // Trying to remove a path that doesn't exist + String badPatch = "[{\"op\":\"remove\",\"path\":\"/nonExistentField/deeply/nested\"}]"; + + assertThrows(InvalidRequestException.class, + () -> applyPatchToPatientJson(originalJson, badPatch)); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 3. INCLUDE_SOURCE_IN_LOCATION is disabled + // This is the key Jackson config in JsonPatchUtils: the feature that + // would include raw input in exception messages is explicitly turned OFF + // for privacy/security. This must survive the LFJT3 uplift where + // JsonParser.Feature → StreamReadFeature. + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Privacy — INCLUDE_SOURCE_IN_LOCATION disabled") + class PrivacyTests { + + @Test + @DisplayName("Exception message from bad patch does not contain raw input data") + void patch_invalidInput_exceptionDoesNotLeakRawInput() { + String sensitiveJson = buildPatientJson("pt-sensitive", "SecretName"); + String badPatch = "DEFINITELY_NOT_JSON"; + + try { + applyPatchToPatientJson(sensitiveJson, badPatch); + } catch (InvalidRequestException e) { + // The exception message must NOT contain the raw input payload. + // INCLUDE_SOURCE_IN_LOCATION=false ensures Jackson strips source from errors. + assertThat(e.getMessage()) + .as("Exception message must not leak raw input data (INCLUDE_SOURCE_IN_LOCATION=false)") + .doesNotContain("SecretName"); + } + } + } } diff --git a/hapi-fhir-server-cds-hooks/src/test/java/ca/uhn/hapi/fhir/cdshooks/svc/CdsHooksContextBooterJacksonTest.java b/hapi-fhir-server-cds-hooks/src/test/java/ca/uhn/hapi/fhir/cdshooks/svc/CdsHooksContextBooterJacksonTest.java new file mode 100644 index 000000000000..efe8a77a3408 --- /dev/null +++ b/hapi-fhir-server-cds-hooks/src/test/java/ca/uhn/hapi/fhir/cdshooks/svc/CdsHooksContextBooterJacksonTest.java @@ -0,0 +1,177 @@ +package ca.uhn.hapi.fhir.cdshooks.svc; + +/* + * LFJT3 — "Looking Forward to Jackson Tools 3" + * ============================================= + * Written for Jackson 2 (com.fasterxml.jackson). Only the import block + * and createMapper() factory method change during the future LFJT3 uplift. + * + * LFJT3 MIGRATION CHECKLIST + * -------------------------- + * [ ] Imports: com.fasterxml.jackson.* → tools.jackson.* + * [ ] createMapper(): new ObjectMapper() → JsonMapper.builder().build() + * + * KEY BEHAVIORS LOCKED DOWN (CdsHooksContextBooter.serializeExtensions) + * ----------------------------------------------------------------------- + * - Valid JSON string → CdsHooksExtension subclass instance + * - Empty/blank string → null (no deserialization attempted) + * - Invalid JSON → UnprocessableEntityException + * - Null extensionClass → handled upstream (not tested here) + * + * NOTE: serializeExtensions() uses `new ObjectMapper()` internally. + * In LFJT3 that becomes `JsonMapper.builder().build()`. The behavior under + * test is Jackson-version-agnostic: the output is the deserialized object, + * not the wire format. + */ + +import ca.uhn.fhir.rest.api.server.cdshooks.CdsHooksExtension; +import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException; +import com.fasterxml.jackson.annotation.JsonProperty; +// ── LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────────── +// Jackson 2 (NOW): +import com.fasterxml.jackson.databind.ObjectMapper; +// Jackson 3 (LFJT3): +// import tools.jackson.databind.ObjectMapper; +// ── END LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────── +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * LFJT3-aware tests for the Jackson-relevant behavior in + * {@link CdsHooksContextBooter#serializeExtensions(String, Class)}. + */ +class CdsHooksContextBooterJacksonTest { + + private CdsHooksContextBooter myBooter; + + // ── LFJT3 MAPPER FACTORY ───────────────────────────────────────────────── + // Not used directly in this test (the booter creates its own mapper internally), + // but retained here to document the LFJT3 change point clearly. + // When LFJT3 uplift lands in CdsHooksContextBooter.serializeExtensions(), + // the internal `new ObjectMapper()` becomes `JsonMapper.builder().build()`. + // Jackson 2 (NOW): new ObjectMapper() + // Jackson 3 (LFJT3): JsonMapper.builder().build() + private ObjectMapper createMapper() { + return new ObjectMapper(); + // LFJT3: return JsonMapper.builder().build(); + } + // ── END LFJT3 MAPPER FACTORY ───────────────────────────────────────────── + + /** Minimal CdsHooksExtension subclass for testing. */ + static class TestExtension extends CdsHooksExtension { + @JsonProperty("configItem") + // @JsonProperty stays on com.fasterxml.jackson.annotation — never moves + private String configItem; + + public String getConfigItem() { return configItem; } + public void setConfigItem(String theConfigItem) { configItem = theConfigItem; } + } + + @BeforeEach + void setUp() { + myBooter = new CdsHooksContextBooter(); + } + + // ───────────────────────────────────────────────────────────────────────── + // 1. serializeExtensions — valid JSON + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("serializeExtensions — valid JSON") + class ValidJsonTests { + + @Test + @DisplayName("Valid JSON deserializes to the correct extension type") + void serializeExtensions_validJson_returnsExtensionInstance() { + String json = "{\"configItem\":\"test-value\"}"; + + CdsHooksExtension result = myBooter.serializeExtensions(json, TestExtension.class); + + assertThat(result).isInstanceOf(TestExtension.class); + } + + @Test + @DisplayName("Extension field value is correctly deserialized") + void serializeExtensions_validJson_fieldValueCorrect() { + String json = "{\"configItem\":\"hello-world\"}"; + + TestExtension result = (TestExtension) myBooter.serializeExtensions(json, TestExtension.class); + + assertThat(result.getConfigItem()).isEqualTo("hello-world"); + } + + @Test + @DisplayName("Valid JSON with unknown fields does not throw") + void serializeExtensions_unknownFields_doesNotThrow() { + String json = "{\"configItem\":\"val\",\"unknownField\":\"ignored\"}"; + + // Jackson ignores unknown fields by default — must still succeed + CdsHooksExtension result = myBooter.serializeExtensions(json, TestExtension.class); + + assertThat(result).isNotNull(); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 2. serializeExtensions — empty / blank input + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("serializeExtensions — empty/blank input") + class EmptyInputTests { + + @Test + @DisplayName("Empty string returns null (isEmpty catches empty string)") + void serializeExtensions_emptyString_returnsNull() { + // StringUtils.isEmpty("") == true → returns null before deserialization + CdsHooksExtension result = myBooter.serializeExtensions("", TestExtension.class); + assertNull(result); + } + + @Test + @DisplayName("Null string returns null (isEmpty catches null)") + void serializeExtensions_nullString_returnsNull() { + // StringUtils.isEmpty(null) == true → returns null before deserialization + CdsHooksExtension result = myBooter.serializeExtensions(null, TestExtension.class); + assertNull(result); + } + + @Test + @DisplayName("Blank/whitespace string throws UnprocessableEntityException") + void serializeExtensions_blankString_throwsUnprocessableEntity() { + // StringUtils.isEmpty(" ") == FALSE — whitespace passes the guard + // and reaches mapper.readValue(), which fails on non-JSON input + // LFJT3 NOTE: this behavior is unchanged — isEmpty() guard remains the same + assertThrows(UnprocessableEntityException.class, + () -> myBooter.serializeExtensions(" ", TestExtension.class)); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 3. serializeExtensions — invalid JSON + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("serializeExtensions — invalid JSON") + class InvalidJsonTests { + + @Test + @DisplayName("Malformed JSON throws UnprocessableEntityException") + void serializeExtensions_malformedJson_throwsUnprocessableEntity() { + String badJson = "{this is not valid json}"; + + assertThrows(UnprocessableEntityException.class, + () -> myBooter.serializeExtensions(badJson, TestExtension.class)); + } + + @Test + @DisplayName("Plain text (not JSON) throws UnprocessableEntityException") + void serializeExtensions_plainText_throwsUnprocessableEntity() { + assertThrows(UnprocessableEntityException.class, + () -> myBooter.serializeExtensions("hello world", TestExtension.class)); + } + } +} diff --git a/hapi-fhir-server-cds-hooks/src/test/java/ca/uhn/hapi/fhir/cdshooks/svc/CdsServiceRequestContextSerializerDeserializerTest.java b/hapi-fhir-server-cds-hooks/src/test/java/ca/uhn/hapi/fhir/cdshooks/svc/CdsServiceRequestContextSerializerDeserializerTest.java new file mode 100644 index 000000000000..f808c2202b76 --- /dev/null +++ b/hapi-fhir-server-cds-hooks/src/test/java/ca/uhn/hapi/fhir/cdshooks/svc/CdsServiceRequestContextSerializerDeserializerTest.java @@ -0,0 +1,309 @@ +package ca.uhn.hapi.fhir.cdshooks.serializer; + +/* + * LFJT3 — "Looking Forward to Jackson Tools 3" + * ============================================= + * Written for Jackson 2 (com.fasterxml.jackson). Only the import block + * and createMapper() factory method change during the LFJT3 uplift. + * All @Test methods need zero changes. + * + * LFJT3 MIGRATION CHECKLIST + * -------------------------- + * [ ] Imports: + * com.fasterxml.jackson.databind.ObjectMapper → tools.jackson.databind.ObjectMapper + * com.fasterxml.jackson.databind.JsonNode → tools.jackson.databind.JsonNode + * com.fasterxml.jackson.databind.module.SimpleModule → tools.jackson.databind.module.SimpleModule + * [ ] createMapper(): new ObjectMapper() → JsonMapper.builder().build() + * + * KEY BEHAVIORS LOCKED DOWN + * ------------------------- + * CdsServiceRequestContextSerializer: + * - Each context entry is written as a JSON object/value, NOT a quoted string + * (uses writeRawValue() not writeString()) + * - writeFieldName(key) [Jackson 2] → writeName(key) [Jackson 3] is a + * PRODUCTION CLASS change, not a test change — tests assert on output only + * + * CdsServiceRequestContextDeserializer: + * - LinkedHashMap entries that are maps are re-parsed as IBaseResource + * - Scalar values are passed through as-is + * - theJsonParser.getCodec().readTree() [Jackson 2] → + * theJsonParser.readValueAsTree() [Jackson 3] — production class change only + */ + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.rest.api.server.cdshooks.CdsServiceRequestContextJson; +// ── LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────────── +// Jackson 2 (NOW): +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +// Jackson 3 (LFJT3): +// import tools.jackson.databind.JsonNode; +// import tools.jackson.databind.ObjectMapper; +// import tools.jackson.databind.module.SimpleModule; +// ── END LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────── +import org.hl7.fhir.r4.model.Patient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * LFJT3-aware tests for {@link CdsServiceRequestContextSerializer} and + * {@link CdsServiceRequestContextDeserializer}. + */ +class CdsServiceRequestContextSerializerDeserializerTest { + + private static final FhirContext FHIR_CONTEXT = FhirContext.forR4(); + + private ObjectMapper myMapper; + + // ── LFJT3 MAPPER FACTORY ───────────────────────────────────────────────── + // Jackson 2 (NOW): new ObjectMapper() + // Jackson 3 (LFJT3): JsonMapper.builder().build() + private ObjectMapper createMapper() { + // Note: CdsServiceRequestContextSerializer/Deserializer take the mapper + // as a constructor arg — this mirrors CdsHooksObjectMapperFactory.newMapper() + ObjectMapper baseMapper = new ObjectMapper(); + // LFJT3: ObjectMapper baseMapper = JsonMapper.builder().build(); + + SimpleModule module = new SimpleModule(); + module.addSerializer(new CdsServiceRequestContextSerializer(FHIR_CONTEXT, baseMapper)); + module.addDeserializer( + CdsServiceRequestContextJson.class, + new CdsServiceRequestContextDeserializer(FHIR_CONTEXT, baseMapper)); + return baseMapper.registerModule(module); + // LFJT3: return JsonMapper.builder().addModule(module).build(); + } + // ── END LFJT3 MAPPER FACTORY ───────────────────────────────────────────── + + @BeforeEach + void setUp() { + myMapper = createMapper(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Wrapper so we can embed CdsServiceRequestContextJson in an outer object + // ───────────────────────────────────────────────────────────────────────── + static class ContextHolder { + public CdsServiceRequestContextJson context; + } + + // ───────────────────────────────────────────────────────────────────────── + // 1. CdsServiceRequestContextSerializer + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("CdsServiceRequestContextSerializer") + class SerializerTests { + + @Test + @DisplayName("Scalar string entry is serialized as a JSON string value") + void serialize_scalarString_isJsonString() throws Exception { + CdsServiceRequestContextJson context = new CdsServiceRequestContextJson(); + context.put("patientId", "Patient/123"); + + String json = myMapper.writeValueAsString(context); + JsonNode root = myMapper.readTree(json); + + assertThat(root.get("patientId").asText()).isEqualTo("Patient/123"); + } + + @Test + @DisplayName("FHIR resource entry is embedded as JSON object, not quoted string") + void serialize_fhirResource_isJsonObject() throws Exception { + Patient patient = new Patient(); + patient.setId("pt-001"); + + CdsServiceRequestContextJson context = new CdsServiceRequestContextJson(); + context.put("patient", patient); + + String json = myMapper.writeValueAsString(context); + JsonNode root = myMapper.readTree(json); + JsonNode patientNode = root.get("patient"); + + // Must be a JSON object, not a quoted/escaped string + assertNotNull(patientNode, "patient field must be present"); + assertTrue(patientNode.isObject(), + "patient must be embedded as a JSON object (writeRawValue), not a quoted string (writeString)"); + } + + @Test + @DisplayName("FHIR resource entry contains resourceType") + void serialize_fhirResource_hasResourceType() throws Exception { + Patient patient = new Patient(); + patient.setId("pt-002"); + + CdsServiceRequestContextJson context = new CdsServiceRequestContextJson(); + context.put("patient", patient); + + String json = myMapper.writeValueAsString(context); + JsonNode patientNode = myMapper.readTree(json).get("patient"); + + assertThat(patientNode.get("resourceType").asText()).isEqualTo("Patient"); + } + + @Test + @DisplayName("Mixed context (scalar + FHIR resource) serializes both correctly") + void serialize_mixedContext() throws Exception { + Patient patient = new Patient(); + patient.setId("pt-003"); + + CdsServiceRequestContextJson context = new CdsServiceRequestContextJson(); + context.put("patientId", "Patient/123"); + context.put("patient", patient); + + String json = myMapper.writeValueAsString(context); + JsonNode root = myMapper.readTree(json); + + assertThat(root.get("patientId").asText()).isEqualTo("Patient/123"); + assertTrue(root.get("patient").isObject()); + } + + @Test + @DisplayName("Produces valid JSON (parseable without exception)") + void serialize_producesValidJson() throws Exception { + CdsServiceRequestContextJson context = new CdsServiceRequestContextJson(); + context.put("userId", "Practitioner/P1"); + + assertDoesNotThrow(() -> { + String json = myMapper.writeValueAsString(context); + myMapper.readTree(json); + }); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 2. CdsServiceRequestContextDeserializer + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("CdsServiceRequestContextDeserializer") + class DeserializerTests { + + @Test + @DisplayName("Scalar string entry is deserialized as String") + void deserialize_scalarString_isString() throws Exception { + String json = "{\"patientId\":\"Patient/123\"}"; + + CdsServiceRequestContextJson context = + myMapper.readValue(json, CdsServiceRequestContextJson.class); + + assertThat(context.get("patientId")).isEqualTo("Patient/123"); + } + + @Test + @DisplayName("FHIR resource entry (as nested map) is deserialized as IBaseResource") + void deserialize_fhirResourceMap_isIBaseResource() throws Exception { + String json = "{" + + "\"patient\":{" + + "\"resourceType\":\"Patient\"," + + "\"id\":\"pt-abc\"" + + "}" + + "}"; + + CdsServiceRequestContextJson context = + myMapper.readValue(json, CdsServiceRequestContextJson.class); + + Object patient = context.get("patient"); + assertNotNull(patient); + assertThat(patient).isInstanceOf(org.hl7.fhir.instance.model.api.IBaseResource.class); + } + + @Test + @DisplayName("FHIR resource round-trip preserves patient ID") + void deserialize_fhirResource_preservesId() throws Exception { + String json = "{" + + "\"patient\":{" + + "\"resourceType\":\"Patient\"," + + "\"id\":\"pt-round-trip\"" + + "}" + + "}"; + + CdsServiceRequestContextJson context = + myMapper.readValue(json, CdsServiceRequestContextJson.class); + + Patient patient = (Patient) context.get("patient"); + assertThat(patient.getIdElement().getIdPart()).isEqualTo("pt-round-trip"); + } + + @Test + @DisplayName("Mixed context (scalar + resource) deserializes both correctly") + void deserialize_mixedContext() throws Exception { + String json = "{" + + "\"userId\":\"Practitioner/P1\"," + + "\"patient\":{" + + "\"resourceType\":\"Patient\"," + + "\"id\":\"pt-mixed\"" + + "}" + + "}"; + + CdsServiceRequestContextJson context = + myMapper.readValue(json, CdsServiceRequestContextJson.class); + + assertThat(context.get("userId")).isEqualTo("Practitioner/P1"); + assertThat(context.get("patient")).isInstanceOf(org.hl7.fhir.instance.model.api.IBaseResource.class); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 3. Round-trip: serialize → deserialize + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Round-trip: serialize → deserialize") + class RoundTripTests { + + @Test + @DisplayName("FHIR Patient round-trip preserves resourceType and id") + void roundTrip_fhirPatient() throws Exception { + Patient original = new Patient(); + original.setId("rt-patient-1"); + + CdsServiceRequestContextJson originalContext = new CdsServiceRequestContextJson(); + originalContext.put("patient", original); + + String json = myMapper.writeValueAsString(originalContext); + CdsServiceRequestContextJson reparsed = + myMapper.readValue(json, CdsServiceRequestContextJson.class); + + Patient reparsedPatient = (Patient) reparsed.get("patient"); + assertThat(reparsedPatient.getIdElement().getIdPart()).isEqualTo("rt-patient-1"); + } + + @Test + @DisplayName("Scalar string round-trip preserves value") + void roundTrip_scalarString() throws Exception { + CdsServiceRequestContextJson originalContext = new CdsServiceRequestContextJson(); + originalContext.put("hookInstance", "uuid-999"); + + String json = myMapper.writeValueAsString(originalContext); + CdsServiceRequestContextJson reparsed = + myMapper.readValue(json, CdsServiceRequestContextJson.class); + + assertThat(reparsed.get("hookInstance")).isEqualTo("uuid-999"); + } + + @Test + @DisplayName("Multiple keys all survive round-trip") + void roundTrip_multipleKeys() throws Exception { + Patient patient = new Patient(); + patient.setId("multi-pt"); + + CdsServiceRequestContextJson originalContext = new CdsServiceRequestContextJson(); + originalContext.put("patientId", "Patient/multi-pt"); + originalContext.put("userId", "Practitioner/dr-jones"); + originalContext.put("patient", patient); + + String json = myMapper.writeValueAsString(originalContext); + CdsServiceRequestContextJson reparsed = + myMapper.readValue(json, CdsServiceRequestContextJson.class); + + assertThat(reparsed.get("patientId")).isEqualTo("Patient/multi-pt"); + assertThat(reparsed.get("userId")).isEqualTo("Practitioner/dr-jones"); + assertThat(reparsed.get("patient")).isInstanceOf(org.hl7.fhir.instance.model.api.IBaseResource.class); + } + } +} diff --git a/hapi-fhir-server-cds-hooks/src/test/java/ca/uhn/hapi/fhir/cdshooks/svc/CdsSvcJacksonTest.java b/hapi-fhir-server-cds-hooks/src/test/java/ca/uhn/hapi/fhir/cdshooks/svc/CdsSvcJacksonTest.java new file mode 100644 index 000000000000..28b0281631f3 --- /dev/null +++ b/hapi-fhir-server-cds-hooks/src/test/java/ca/uhn/hapi/fhir/cdshooks/svc/CdsSvcJacksonTest.java @@ -0,0 +1,317 @@ +package ca.uhn.hapi.fhir.cdshooks.svc; + +/* + * LFJT3 — "Looking Forward to Jackson Tools 3" + * ============================================= + * Contains LFJT3-aware Jackson 2 tests for: + * - BaseCdsMethod (Jackson: encodeRequest → writeValueAsString) + * - BaseDynamicCdsServiceMethod (no Jackson in the class itself — documented) + * - CdsConfigServiceImpl (no Jackson behavior — documented) + * - CdsServiceRegistryImpl (Jackson: buildResponseFromString, buildFeedbackFromString) + * + * Only the import block and createMapper() factory method change during LFJT3. + * + * LFJT3 MIGRATION CHECKLIST + * -------------------------- + * [ ] Imports: com.fasterxml.jackson.* → tools.jackson.* + * [ ] createMapper(): new ObjectMapper() → JsonMapper.builder().build() + * [ ] BaseCdsMethod.encodeRequest(): catch (JsonProcessingException e) + * → catch (JacksonException e) — production class change only + * [ ] CdsServiceRegistryImpl.buildResponseFromString(): same catch swap + * [ ] CdsServiceRegistryImpl.buildFeedbackFromString(): same catch swap + */ + +import ca.uhn.fhir.context.ConfigurationException; +import ca.uhn.fhir.model.api.IModelJson; +import ca.uhn.fhir.rest.api.server.cdshooks.CdsServiceRequestJson; +import ca.uhn.hapi.fhir.cdshooks.api.json.CdsServiceFeedbackJson; +import ca.uhn.hapi.fhir.cdshooks.api.json.CdsServiceResponseCardJson; +import ca.uhn.hapi.fhir.cdshooks.api.json.CdsServiceResponseJson; +import com.fasterxml.jackson.annotation.JsonProperty; +// ── LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────────── +// Jackson 2 (NOW): +import com.fasterxml.jackson.databind.ObjectMapper; +// Jackson 3 (LFJT3): +// import tools.jackson.databind.ObjectMapper; +// ── END LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────── +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CdsSvcJacksonTest { + + // ── LFJT3 MAPPER FACTORY ───────────────────────────────────────────────── + // Jackson 2 (NOW): new ObjectMapper() + // Jackson 3 (LFJT3): JsonMapper.builder().build() + private ObjectMapper createMapper() { + return new ObjectMapper(); + // LFJT3: return JsonMapper.builder().build(); + } + // ── END LFJT3 MAPPER FACTORY ───────────────────────────────────────────── + + // ═════════════════════════════════════════════════════════════════════════ + // 1. BaseCdsMethod — Jackson: encodeRequest → writeValueAsString + // The Jackson-sensitive path: when the CDS service method parameter type + // is String, BaseCdsMethod.encodeRequest() serializes the IModelJson to + // a JSON string via ObjectMapper.writeValueAsString(). + // ═════════════════════════════════════════════════════════════════════════ + + /** Minimal IModelJson for testing. */ + static class TestRequestJson implements IModelJson { + @JsonProperty("hookInstance") + // @JsonProperty stays on com.fasterxml.jackson.annotation in Jackson 3 + private String hookInstance; + + TestRequestJson(String hookInstance) { this.hookInstance = hookInstance; } + public String getHookInstance() { return hookInstance; } + } + + /** Concrete service bean that accepts a String parameter. */ + static class StringParamService { + public String handle(String theRequest) { return theRequest; } + } + + /** Concrete service bean that accepts a typed parameter. */ + static class TypedParamService { + public Object handle(TestRequestJson theRequest) { return theRequest; } + } + + @Nested + @DisplayName("BaseCdsMethod — encodeRequest (String parameter path)") + class BaseCdsMethodTests { + + @Test + @DisplayName("When method param is String, IModelJson is serialized to JSON string") + void invoke_stringParam_serializesRequestToJson() throws Exception { + ObjectMapper mapper = createMapper(); + StringParamService bean = new StringParamService(); + Method method = StringParamService.class.getMethod("handle", String.class); + + // Use a concrete anonymous subclass since BaseCdsMethod is abstract + BaseCdsMethod svc = new BaseCdsMethod(bean, method) {}; + + TestRequestJson request = new TestRequestJson("uuid-001"); + Object result = svc.invoke(mapper, request, "test-service"); + + // The result should be a JSON string containing the hookInstance + assertThat(result).isInstanceOf(String.class); + assertThat((String) result).contains("uuid-001"); + } + + @Test + @DisplayName("Serialized string is valid JSON") + void invoke_stringParam_resultIsValidJson() throws Exception { + ObjectMapper mapper = createMapper(); + StringParamService bean = new StringParamService(); + Method method = StringParamService.class.getMethod("handle", String.class); + BaseCdsMethod svc = new BaseCdsMethod(bean, method) {}; + + TestRequestJson request = new TestRequestJson("uuid-002"); + String result = (String) svc.invoke(mapper, request, "test-service"); + + // Verify the string is parseable as JSON + assertDoesNotThrow(() -> mapper.readTree(result)); + } + + @Test + @DisplayName("When method param is typed (not String), IModelJson is passed directly") + void invoke_typedParam_passesRequestDirectly() throws Exception { + ObjectMapper mapper = createMapper(); + TypedParamService bean = new TypedParamService(); + Method method = TypedParamService.class.getMethod("handle", TestRequestJson.class); + BaseCdsMethod svc = new BaseCdsMethod(bean, method) {}; + + TestRequestJson request = new TestRequestJson("uuid-typed"); + Object result = svc.invoke(mapper, request, "test-service"); + + // Passed directly — not serialized to String + assertThat(result).isSameAs(request); + } + } + + // ═════════════════════════════════════════════════════════════════════════ + // 2. BaseDynamicCdsServiceMethod + // No Jackson imports in the class itself — the Function<> does the work. + // LFJT3 NOTE: No changes required in BaseDynamicCdsServiceMethod.java + // for the LFJT3 uplift. The ObjectMapper parameter is typed as + // com.fasterxml.jackson.databind.ObjectMapper → tools.jackson.databind.ObjectMapper + // but the class logic doesn't call any Jackson API directly. + // ═════════════════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("BaseDynamicCdsServiceMethod — function invocation") + class BaseDynamicCdsServiceMethodTests { + + @Test + @DisplayName("Registered function is invoked and returns response") + void invoke_delegatesToFunction() throws Exception { + ObjectMapper mapper = createMapper(); + + CdsServiceResponseJson expectedResponse = new CdsServiceResponseJson(); + CdsServiceResponseCardJson card = new CdsServiceResponseCardJson(); + card.setSummary("Dynamic Response"); + expectedResponse.addCard(card); + + BaseDynamicCdsServiceMethod svc = + new BaseDynamicCdsServiceMethod(req -> expectedResponse) {}; + + CdsServiceRequestJson request = new CdsServiceRequestJson(); + Object result = svc.invoke(mapper, request, "dynamic-service"); + + assertThat(result).isSameAs(expectedResponse); + } + + @Test + @DisplayName("getFunction returns the registered function") + void getFunction_returnsRegisteredFunction() { + java.util.function.Function fn = + req -> new CdsServiceResponseJson(); + + BaseDynamicCdsServiceMethod svc = new BaseDynamicCdsServiceMethod(fn) {}; + + assertThat(svc.getFunction()).isSameAs(fn); + } + } + + // ═════════════════════════════════════════════════════════════════════════ + // 3. CdsConfigServiceImpl + // Pure bean — stores ObjectMapper reference, no Jackson API calls. + // LFJT3 NOTE: Only import changes (ObjectMapper package rename). + // No behavior changes needed. + // ═════════════════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("CdsConfigServiceImpl — ObjectMapper delegation") + class CdsConfigServiceImplTests { + + @Test + @DisplayName("getObjectMapper returns the mapper passed to the constructor") + void getObjectMapper_returnsConstructorArg() { + ObjectMapper mapper = createMapper(); + CdsConfigServiceImpl svc = new CdsConfigServiceImpl( + ca.uhn.fhir.context.FhirContext.forR4(), mapper, null, null); + + assertThat(svc.getObjectMapper()).isSameAs(mapper); + } + + @Test + @DisplayName("getFhirContext returns the context passed to the constructor") + void getFhirContext_returnsConstructorArg() { + ca.uhn.fhir.context.FhirContext ctx = ca.uhn.fhir.context.FhirContext.forR4(); + CdsConfigServiceImpl svc = new CdsConfigServiceImpl(ctx, createMapper(), null, null); + + assertThat(svc.getFhirContext()).isSameAs(ctx); + } + } + + // ═════════════════════════════════════════════════════════════════════════ + // 4. CdsServiceRegistryImpl — Jackson paths: + // encodeServiceResponse(String) → ObjectMapper.readValue → CdsServiceResponseJson + // encodeFeedbackResponse(String) → ObjectMapper.readValue → CdsServiceFeedbackJson + // ═════════════════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("CdsServiceRegistryImpl — Jackson encode paths") + class CdsServiceRegistryImplJacksonTests { + + private CdsServiceRegistryImpl buildRegistryImpl() { + ObjectMapper mapper = createMapper(); + CdsHooksContextBooter booter = mock(CdsHooksContextBooter.class); + when(booter.buildCdsServiceCache()).thenReturn(new CdsServiceCache()); + + ca.uhn.hapi.fhir.cdshooks.svc.prefetch.CdsPrefetchSvc prefetchSvc = + mock(ca.uhn.hapi.fhir.cdshooks.svc.prefetch.CdsPrefetchSvc.class); + + ca.uhn.hapi.fhir.cdshooks.serializer.CdsServiceRequestJsonDeserializer deserializer = + mock(ca.uhn.hapi.fhir.cdshooks.serializer.CdsServiceRequestJsonDeserializer.class); + + CdsServiceRegistryImpl registry = new CdsServiceRegistryImpl( + booter, prefetchSvc, mapper, deserializer); + registry.init(); + return registry; + } + + @Test + @DisplayName("encodeServiceResponse: String result is deserialized to CdsServiceResponseJson") + void encodeServiceResponse_stringResult_returnsDeserializedObject() throws Exception { + CdsServiceRegistryImpl registry = buildRegistryImpl(); + ObjectMapper mapper = createMapper(); + + CdsServiceResponseJson expected = new CdsServiceResponseJson(); + CdsServiceResponseCardJson card = new CdsServiceResponseCardJson(); + card.setSummary("Test Card"); + expected.addCard(card); + + // Serialize the response as it would come back from a String-returning service method + String jsonResponse = mapper.writeValueAsString(expected); + + CdsServiceResponseJson result = registry.encodeServiceResponse("test-svc", jsonResponse); + + assertNotNull(result); + assertThat(result.getCards()).hasSize(1); + assertThat(result.getCards().get(0).getSummary()).isEqualTo("Test Card"); + } + + @Test + @DisplayName("encodeServiceResponse: CdsServiceResponseJson result is returned directly (no Jackson)") + void encodeServiceResponse_typedResult_returnedDirectly() { + CdsServiceRegistryImpl registry = buildRegistryImpl(); + + CdsServiceResponseJson response = new CdsServiceResponseJson(); + CdsServiceResponseCardJson card = new CdsServiceResponseCardJson(); + card.setSummary("Direct"); + response.addCard(card); + + CdsServiceResponseJson result = registry.encodeServiceResponse("test-svc", response); + + assertThat(result).isSameAs(response); + } + + @Test + @DisplayName("encodeServiceResponse: invalid JSON string throws ConfigurationException") + void encodeServiceResponse_invalidJson_throwsConfigurationException() { + CdsServiceRegistryImpl registry = buildRegistryImpl(); + + assertThrows(ConfigurationException.class, + () -> registry.encodeServiceResponse("test-svc", "not valid json")); + } + + @Test + @DisplayName("encodeFeedbackResponse: String result is deserialized to CdsServiceFeedbackJson") + void encodeFeedbackResponse_stringResult_returnsDeserializedObject() throws Exception { + CdsServiceRegistryImpl registry = buildRegistryImpl(); + ObjectMapper mapper = createMapper(); + + CdsServiceFeedbackJson expected = new CdsServiceFeedbackJson(); + expected.setCard("card-uuid-123"); + + String jsonResponse = mapper.writeValueAsString(expected); + + CdsServiceFeedbackJson result = registry.encodeFeedbackResponse("test-svc", jsonResponse); + + assertNotNull(result); + assertThat(result.getCard()).isEqualTo("card-uuid-123"); + } + + @Test + @DisplayName("encodeFeedbackResponse: invalid JSON string throws RuntimeException") + void encodeFeedbackResponse_invalidJson_throwsRuntimeException() { + CdsServiceRegistryImpl registry = buildRegistryImpl(); + + assertThrows(RuntimeException.class, + () -> registry.encodeFeedbackResponse("test-svc", "{bad json}")); + } + } +} diff --git a/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/util/JsonDateSerializerDeserializerTest.java b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/util/JsonDateSerializerDeserializerTest.java new file mode 100644 index 000000000000..e616fd1543b2 --- /dev/null +++ b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/util/JsonDateSerializerDeserializerTest.java @@ -0,0 +1,257 @@ +package ca.uhn.fhir.rest.server.util; + +/* + * LFJT3 — "Looking Forward to Jackson Tools 3" + * ============================================= + * Written for Jackson 2 (com.fasterxml.jackson). Structured so that only + * the two clearly marked sections change during the future LFJT3 uplift: + * + * 1. "── LFJT3 JACKSON IMPORT BLOCK ──" + * 2. "── LFJT3 MAPPER FACTORY ──" + * + * All @Test methods are Jackson-version-agnostic and need zero changes. + * + * LFJT3 MIGRATION CHECKLIST + * -------------------------- + * [ ] Imports: + * com.fasterxml.jackson.databind.JsonDeserializer → tools.jackson.databind.ValueDeserializer + * com.fasterxml.jackson.databind.JsonSerializer → tools.jackson.databind.ValueSerializer + * com.fasterxml.jackson.databind.ObjectMapper → tools.jackson.databind.ObjectMapper + * com.fasterxml.jackson.databind.module.SimpleModule → tools.jackson.databind.module.SimpleModule + * [ ] createMapper() factory: new ObjectMapper() → JsonMapper.builder().build() + * [ ] JsonDateDeserializer.deserialize() loses "throws IOException" in Jackson 3 — + * but that change is in the production class, not here. + * [ ] JsonDateSerializer.serialize() loses "throws IOException" in Jackson 3 — + * same — production class change only. + */ + +import ca.uhn.fhir.model.primitive.DateTimeDt; +import ca.uhn.fhir.model.primitive.InstantDt; + +// ── LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────────── +// Jackson 2 (NOW — do not change yet): +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +// Jackson 3 (LFJT3 — swap the three lines above for): +// import com.fasterxml.jackson.annotation.JsonProperty; // stays — annotations never move +// import tools.jackson.databind.ObjectMapper; +// import tools.jackson.databind.module.SimpleModule; +// ── END LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────── + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.Date; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * LFJT3-aware unit tests for {@link JsonDateDeserializer} and {@link JsonDateSerializer}. + * + *

Tests run under Jackson 2. The {@link #createMapper()} factory method is the + * single change point for LFJT3 — see the migration checklist in the file header. + */ +class JsonDateSerializerDeserializerTest { + + private ObjectMapper myMapper; + + // ── LFJT3 MAPPER FACTORY ───────────────────────────────────────────────── + // Jackson 2 (NOW): new ObjectMapper() + // Jackson 3 (LFJT3): JsonMapper.builder().build() + private ObjectMapper createMapper() { + SimpleModule module = new SimpleModule(); + module.addSerializer(Date.class, new JsonDateSerializer()); + module.addDeserializer(Date.class, new JsonDateDeserializer()); + return new ObjectMapper().registerModule(module); + // LFJT3: return JsonMapper.builder().addModule(module).build(); + } + // ── END LFJT3 MAPPER FACTORY ───────────────────────────────────────────── + + @BeforeEach + void setUp() { + myMapper = createMapper(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Wrapper for round-trip tests — uses @JsonProperty (stays com.fasterxml in Jackson 3) + // ───────────────────────────────────────────────────────────────────────── + static class DateHolder { + @JsonProperty("date") + public Date date; + } + + // ───────────────────────────────────────────────────────────────────────── + // 1. JsonDateSerializer + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("JsonDateSerializer") + class SerializerTests { + + @Test + @DisplayName("Serializes Date as ISO-8601 string") + void serialize_producesIso8601String() throws Exception { + DateHolder holder = new DateHolder(); + holder.date = new InstantDt("2024-06-01T10:00:00Z").getValue(); + + String json = myMapper.writeValueAsString(holder); + + // Value must be a quoted string, not a number + assertThat(json).contains("\"date\""); + assertThat(json).contains("2024-06-01"); + } + + @Test + @DisplayName("Serializes null Date without error") + void serialize_nullDate_producesNullJson() throws Exception { + DateHolder holder = new DateHolder(); + holder.date = null; + + String json = assertDoesNotThrow(() -> myMapper.writeValueAsString(holder)); + // null value → null in JSON + assertThat(json).contains("null"); + } + + @Test + @DisplayName("Serialized value is parseable by HAPI InstantDt") + void serialize_producesValueParseableByHapi() throws Exception { + Date original = new InstantDt("2024-03-15T14:30:00Z").getValue(); + DateHolder holder = new DateHolder(); + holder.date = original; + + String json = myMapper.writeValueAsString(holder); + + // Extract the string value between the quotes after "date": + // Use Jackson to read it back as a raw string + String dateValue = myMapper.readTree(json).get("date").asText(); + + // Verify HAPI can parse the output + assertDoesNotThrow(() -> new InstantDt(dateValue), + "Serialized value must be parseable by HAPI InstantDt"); + } + + @Test + @DisplayName("Serialized output is not an epoch-millis number") + void serialize_isNotEpochMillis() throws Exception { + DateHolder holder = new DateHolder(); + holder.date = new Date(); + + String json = myMapper.writeValueAsString(holder); + String dateNode = myMapper.readTree(json).get("date").toString(); + + // Must start with a quote (string), not a digit (epoch) + assertThat(dateNode).startsWith("\""); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 2. JsonDateDeserializer + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("JsonDateDeserializer") + class DeserializerTests { + + @Test + @DisplayName("Deserializes ISO-8601 string to Date") + void deserialize_iso8601String_returnsDate() throws Exception { + String json = "{\"date\":\"2024-06-01T10:00:00.000Z\"}"; + + DateHolder holder = myMapper.readValue(json, DateHolder.class); + + assertNotNull(holder.date, "Deserialized Date must not be null"); + // Verify round-trip value matches the HAPI-parsed date + Date expected = new DateTimeDt("2024-06-01T10:00:00.000Z").getValue(); + assertThat(holder.date).isEqualTo(expected); + } + + @Test + @DisplayName("Deserializes null JSON value to null Date") + void deserialize_nullJson_returnsNull() throws Exception { + String json = "{\"date\":null}"; + DateHolder holder = myMapper.readValue(json, DateHolder.class); + assertNull(holder.date, "null JSON value must deserialize to null Date"); + } + + @Test + @DisplayName("Deserializes date-only string (no time component)") + void deserialize_dateOnlyString_returnsDate() throws Exception { + String json = "{\"date\":\"2024-06-01\"}"; + DateHolder holder = myMapper.readValue(json, DateHolder.class); + assertNotNull(holder.date); + } + + @Test + @DisplayName("Deserializes empty string to null") + void deserialize_emptyString_returnsNull() throws Exception { + String json = "{\"date\":\"\"}"; + DateHolder holder = myMapper.readValue(json, DateHolder.class); + assertNull(holder.date, + "Empty string must deserialize to null (matches isNotBlank check in production code)"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 3. Round-trip: Serialize then Deserialize + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Round-trip: serialize → deserialize") + class RoundTripTests { + + @Test + @DisplayName("Date survives full round-trip with second precision") + void roundTrip_secondPrecision() throws Exception { + Date original = new InstantDt("2024-06-15T08:30:00Z").getValue(); + DateHolder holder = new DateHolder(); + holder.date = original; + + String json = myMapper.writeValueAsString(holder); + DateHolder reparsed = myMapper.readValue(json, DateHolder.class); + + assertNotNull(reparsed.date); + // Compare as strings via HAPI to avoid millisecond rounding differences + String originalStr = new InstantDt(original).getValueAsString(); + String reparsedStr = new InstantDt(reparsed.date).getValueAsString(); + assertThat(reparsedStr).isEqualTo(originalStr); + } + + @Test + @DisplayName("Null Date survives full round-trip") + void roundTrip_nullDate() throws Exception { + DateHolder holder = new DateHolder(); + holder.date = null; + + String json = myMapper.writeValueAsString(holder); + DateHolder reparsed = myMapper.readValue(json, DateHolder.class); + + assertNull(reparsed.date); + } + + @Test + @DisplayName("Multiple different dates round-trip independently") + void roundTrip_multipleDates() throws Exception { + String[] isoStrings = { + "2020-01-01T00:00:00Z", + "2024-12-31T23:59:59Z", + "2000-06-15T12:00:00Z" + }; + + for (String iso : isoStrings) { + Date original = new InstantDt(iso).getValue(); + DateHolder holder = new DateHolder(); + holder.date = original; + + String json = myMapper.writeValueAsString(holder); + DateHolder reparsed = myMapper.readValue(json, DateHolder.class); + + assertThat(new InstantDt(reparsed.date).getValueAsString()) + .as("Round-trip failed for: " + iso) + .isEqualTo(new InstantDt(original).getValueAsString()); + } + } + } +} diff --git a/hapi-fhir-storage/src/test/java/ca/uhn/fhir/jpa/binary/api/StoredDetailsTest.java b/hapi-fhir-storage/src/test/java/ca/uhn/fhir/jpa/binary/api/StoredDetailsTest.java new file mode 100644 index 000000000000..3690d81c44a9 --- /dev/null +++ b/hapi-fhir-storage/src/test/java/ca/uhn/fhir/jpa/binary/api/StoredDetailsTest.java @@ -0,0 +1,331 @@ +package ca.uhn.fhir.jpa.binary.api; + +/* + * LFJT3 — "Looking Forward to Jackson Tools 3" + * ============================================= + * Written for Jackson 2 (com.fasterxml.jackson). Structured so that only + * the two clearly marked sections change during the future LFJT3 uplift: + * + * 1. "── LFJT3 JACKSON IMPORT BLOCK ──" + * 2. "── LFJT3 MAPPER FACTORY ──" + * + * All @Test methods are Jackson-version-agnostic and need zero changes. + * + * LFJT3 MIGRATION CHECKLIST + * -------------------------- + * [ ] Imports: + * com.fasterxml.jackson.databind.ObjectMapper → tools.jackson.databind.ObjectMapper + * com.fasterxml.jackson.databind.JsonNode → tools.jackson.databind.JsonNode + * NOTE: com.fasterxml.jackson.annotation.* stays unchanged in Jackson 3 + * (@JsonProperty, @JsonSerialize, @JsonDeserialize on the production class) + * BUT @JsonSerialize/@JsonDeserialize are in com.fasterxml.jackson.databind.annotation + * and THOSE move to tools.jackson.databind.annotation (production class change) + * [ ] createMapper() factory: new ObjectMapper() → JsonMapper.builder().build() + * + * KEY CONTRACT THIS TEST LOCKS DOWN + * ---------------------------------- + * StoredDetails uses @JsonSerialize(using=JsonDateSerializer.class) and + * @JsonDeserialize(using=JsonDateDeserializer.class) on the `published` field. + * After LFJT3 uplift, that annotation package moves but the round-trip behavior + * must remain identical. These tests lock down that behavior. + */ + +// ── LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────────── +// Jackson 2 (NOW): +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +// Jackson 3 (LFJT3): +// import tools.jackson.databind.JsonNode; +// import tools.jackson.databind.ObjectMapper; +// ── END LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────── + +import com.google.common.hash.Hashing; +import com.google.common.hash.HashingInputStream; +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * LFJT3-aware unit tests for {@link StoredDetails}. + * + *

Focuses on Jackson serialization/deserialization behavior — particularly + * the custom Date serializer/deserializer on the {@code published} field, and + * the legacy {@code blobId} → {@code binaryContentId} backward-compat field. + */ +class StoredDetailsTest { + + // ── LFJT3 MAPPER FACTORY ───────────────────────────────────────────────── + // Jackson 2 (NOW): new ObjectMapper() + // Jackson 3 (LFJT3): JsonMapper.builder().build() + private ObjectMapper createMapper() { + return new ObjectMapper(); + // LFJT3: return JsonMapper.builder().build(); + } + // ── END LFJT3 MAPPER FACTORY ───────────────────────────────────────────── + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + private StoredDetails buildStoredDetails(String binaryContentId, String contentType, Date published) + throws IOException { + byte[] data = "test-binary-data".getBytes(StandardCharsets.UTF_8); + HashingInputStream his = new HashingInputStream( + Hashing.sha256(), new ByteArrayInputStream(data)); + IOUtils.consume(his); + return new StoredDetails(binaryContentId, data.length, contentType, his, published); + } + + // ───────────────────────────────────────────────────────────────────────── + // 1. Basic serialization structure + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Serialization structure") + class SerializationStructureTests { + + @Test + @DisplayName("Serialized JSON contains expected top-level keys") + void serialize_containsExpectedKeys() throws Exception { + ObjectMapper mapper = createMapper(); + StoredDetails details = buildStoredDetails("binary-001", "application/pdf", new Date()); + + JsonNode root = mapper.readTree(mapper.writeValueAsString(details)); + + assertThat(root.has("binaryContentId")).isTrue(); + assertThat(root.has("bytes")).isTrue(); + assertThat(root.has("contentType")).isTrue(); + assertThat(root.has("hash")).isTrue(); + assertThat(root.has("published")).isTrue(); + } + + @Test + @DisplayName("binaryContentId is serialized correctly") + void serialize_binaryContentId() throws Exception { + ObjectMapper mapper = createMapper(); + StoredDetails details = buildStoredDetails("my-binary-id", "image/png", null); + + JsonNode root = mapper.readTree(mapper.writeValueAsString(details)); + + assertThat(root.get("binaryContentId").asText()).isEqualTo("my-binary-id"); + } + + @Test + @DisplayName("bytes field contains the correct byte count") + void serialize_bytesField() throws Exception { + ObjectMapper mapper = createMapper(); + StoredDetails details = buildStoredDetails("id-1", "text/plain", null); + + JsonNode root = mapper.readTree(mapper.writeValueAsString(details)); + + // "test-binary-data" is 16 bytes + assertThat(root.get("bytes").asLong()).isEqualTo(16L); + } + + @Test + @DisplayName("hash field is non-empty after construction") + void serialize_hashFieldPresent() throws Exception { + ObjectMapper mapper = createMapper(); + StoredDetails details = buildStoredDetails("id-1", "text/plain", null); + + JsonNode root = mapper.readTree(mapper.writeValueAsString(details)); + + assertThat(root.get("hash").asText()).isNotBlank(); + } + + @Test + @DisplayName("contentType field is serialized correctly") + void serialize_contentType() throws Exception { + ObjectMapper mapper = createMapper(); + StoredDetails details = buildStoredDetails("id-1", "application/fhir+json", null); + + JsonNode root = mapper.readTree(mapper.writeValueAsString(details)); + + assertThat(root.get("contentType").asText()).isEqualTo("application/fhir+json"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 2. Published date — custom @JsonSerialize/@JsonDeserialize + // This is the primary LFJT3-sensitive behavior: the annotation package + // moves from com.fasterxml.jackson.databind.annotation to + // tools.jackson.databind.annotation but behavior must remain identical. + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Published date — custom serializer/deserializer") + class PublishedDateTests { + + @Test + @DisplayName("Null published date serializes to null or is absent") + void serialize_nullPublished_isNullOrAbsent() throws Exception { + ObjectMapper mapper = createMapper(); + StoredDetails details = buildStoredDetails("id-1", "text/plain", null); + + JsonNode root = mapper.readTree(mapper.writeValueAsString(details)); + JsonNode publishedNode = root.path("published"); // path() never returns null + + // Jackson may omit null fields entirely OR write them as JSON null. + // Both are valid — the contract is: no non-null date value is present. + assertThat(publishedNode.isNull() || publishedNode.isMissingNode()) + .as("null published date must serialize to JSON null or be omitted entirely") + .isTrue(); + } + + @Test + @DisplayName("Published date serializes as an ISO-8601 string (not epoch millis)") + void serialize_publishedDate_isIsoString() throws Exception { + ObjectMapper mapper = createMapper(); + Date published = new Date(1717200000000L); // 2024-06-01 + StoredDetails details = buildStoredDetails("id-1", "text/plain", published); + + JsonNode root = mapper.readTree(mapper.writeValueAsString(details)); + JsonNode publishedNode = root.get("published"); + + // Must be a text node (ISO string), NOT a number (epoch millis) + assertThat(publishedNode.isTextual()) + .as("published must be an ISO-8601 string, not epoch millis") + .isTrue(); + assertThat(publishedNode.asText()).contains("2024"); + } + + @Test + @DisplayName("Published date round-trips correctly through serialize/deserialize") + void roundTrip_publishedDate() throws Exception { + ObjectMapper mapper = createMapper(); + Date original = new Date(1717200000000L); + StoredDetails details = buildStoredDetails("id-1", "text/plain", original); + + String json = mapper.writeValueAsString(details); + StoredDetails reparsed = mapper.readValue(json, StoredDetails.class); + + assertNotNull(reparsed.getPublished()); + // Compare at second granularity via the serialized string to avoid + // sub-second rounding differences between Jackson 2 and Jackson 3 + String jsonOriginal = mapper.readTree(json).get("published").asText(); + String jsonReparsed = mapper.readTree(mapper.writeValueAsString(reparsed)) + .get("published").asText(); + assertThat(jsonReparsed).isEqualTo(jsonOriginal); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 3. Legacy blobId backward-compat field + // StoredDetails has a blobId field to support pre-7.2.0 stored data. + // Deserializing old JSON that uses "blobId" must return that value + // via getBinaryContentId(). + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Legacy blobId backward-compatibility") + class LegacyBlobIdTests { + + @Test + @DisplayName("JSON with blobId field deserializes and getBinaryContentId returns it") + void deserialize_legacyBlobId_returnedByGetBinaryContentId() throws Exception { + ObjectMapper mapper = createMapper(); + // This is the old JSON format (pre 7.2.0) — uses "blobId" not "binaryContentId" + String legacyJson = "{" + + "\"blobId\":\"old-blob-id-123\"," + + "\"bytes\":100," + + "\"contentType\":\"application/pdf\"," + + "\"hash\":\"abc123\"" + + "}"; + + StoredDetails details = mapper.readValue(legacyJson, StoredDetails.class); + + // getBinaryContentId() must fall back to blobId when binaryContentId is null + assertThat(details.getBinaryContentId()).isEqualTo("old-blob-id-123"); + } + + @Test + @DisplayName("JSON with both binaryContentId and blobId prefers binaryContentId") + void deserialize_bothFields_prefersBinaryContentId() throws Exception { + ObjectMapper mapper = createMapper(); + String json = "{" + + "\"binaryContentId\":\"new-id\"," + + "\"blobId\":\"old-id\"," + + "\"bytes\":100," + + "\"contentType\":\"text/plain\"," + + "\"hash\":\"xyz\"" + + "}"; + + StoredDetails details = mapper.readValue(json, StoredDetails.class); + + // binaryContentId is set, so it wins over blobId + assertThat(details.getBinaryContentId()).isEqualTo("new-id"); + } + + @Test + @DisplayName("JSON with only binaryContentId (new format) deserializes correctly") + void deserialize_newFormat_binaryContentId() throws Exception { + ObjectMapper mapper = createMapper(); + String json = "{" + + "\"binaryContentId\":\"binary-456\"," + + "\"bytes\":200," + + "\"contentType\":\"image/png\"," + + "\"hash\":\"hashval\"" + + "}"; + + StoredDetails details = mapper.readValue(json, StoredDetails.class); + + assertThat(details.getBinaryContentId()).isEqualTo("binary-456"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 4. Full round-trip + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Full round-trip") + class RoundTripTests { + + @Test + @DisplayName("All fields survive serialize → deserialize") + void roundTrip_allFields() throws Exception { + ObjectMapper mapper = createMapper(); + Date published = new Date(1717200000000L); + StoredDetails original = buildStoredDetails("rt-id-001", "application/fhir+json", published); + + String json = mapper.writeValueAsString(original); + StoredDetails reparsed = mapper.readValue(json, StoredDetails.class); + + assertThat(reparsed.getBinaryContentId()).isEqualTo("rt-id-001"); + assertThat(reparsed.getContentType()).isEqualTo("application/fhir+json"); + assertThat(reparsed.getBytes()).isEqualTo(original.getBytes()); + assertThat(reparsed.getHash()).isEqualTo(original.getHash()); + assertNotNull(reparsed.getPublished()); + } + + @Test + @DisplayName("Serialized JSON is valid (parseable without exception)") + void roundTrip_isValidJson() throws Exception { + ObjectMapper mapper = createMapper(); + StoredDetails details = buildStoredDetails("id-valid", "text/plain", new Date()); + + assertDoesNotThrow(() -> { + String json = mapper.writeValueAsString(details); + mapper.readTree(json); + }); + } + + @Test + @DisplayName("Default constructor produces deserializable empty instance") + void defaultConstructor_isDeserializable() throws Exception { + ObjectMapper mapper = createMapper(); + String json = "{}"; + StoredDetails details = assertDoesNotThrow( + () -> mapper.readValue(json, StoredDetails.class)); + assertNotNull(details); + } + } +} From 57fdcb5192db755526f1e02029d92ca1de12707f Mon Sep 17 00:00:00 2001 From: granadacoder Date: Mon, 29 Jun 2026 00:01:20 -0400 Subject: [PATCH 7/7] fix roundTrip_resourceId --- .../ResourceModifiedMessagePersistenceSvcImplJacksonTest.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/subscription/ResourceModifiedMessagePersistenceSvcImplJacksonTest.java b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/subscription/ResourceModifiedMessagePersistenceSvcImplJacksonTest.java index ebadc11d5f70..25397ca37fb6 100644 --- a/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/subscription/ResourceModifiedMessagePersistenceSvcImplJacksonTest.java +++ b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/subscription/ResourceModifiedMessagePersistenceSvcImplJacksonTest.java @@ -250,7 +250,9 @@ void roundTrip_resourceId() { ResourceModifiedMessage result = mySvc.createResourceModifiedMessageFromEntityWithoutInflation(entity); - assertThat(result.getPayloadId()).isEqualTo("pt-id-rt"); + // getPayloadId() returns the full relative reference e.g. "Patient/pt-id-rt" + // use getPayloadId(fhirContext).getIdPart() for just the id segment + assertThat(result.getPayloadId(FHIR_CONTEXT).getIdPart()).isEqualTo("pt-id-rt"); } } }