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(); + } + } } 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() {