diff --git a/core/citrus-api/src/main/java/org/citrusframework/exceptions/SegmentEvaluationException.java b/core/citrus-api/src/main/java/org/citrusframework/exceptions/SegmentEvaluationException.java new file mode 100644 index 0000000000..7d16c746a0 --- /dev/null +++ b/core/citrus-api/src/main/java/org/citrusframework/exceptions/SegmentEvaluationException.java @@ -0,0 +1,15 @@ +package org.citrusframework.exceptions; + +public final class SegmentEvaluationException extends Exception { + + private final String renderedObject; + + public SegmentEvaluationException(String reason, String renderedObject) { + super(reason); + this.renderedObject = renderedObject; + } + + public String getRenderedObject() { + return renderedObject; + } +} diff --git a/core/citrus-api/src/main/java/org/citrusframework/variable/SegmentVariableExtractorRegistry.java b/core/citrus-api/src/main/java/org/citrusframework/variable/SegmentVariableExtractorRegistry.java index e83768ad4f..c99d237a91 100644 --- a/core/citrus-api/src/main/java/org/citrusframework/variable/SegmentVariableExtractorRegistry.java +++ b/core/citrus-api/src/main/java/org/citrusframework/variable/SegmentVariableExtractorRegistry.java @@ -16,6 +16,15 @@ package org.citrusframework.variable; +import org.citrusframework.context.TestContext; +import org.citrusframework.exceptions.CitrusRuntimeException; +import org.citrusframework.exceptions.SegmentEvaluationException; +import org.citrusframework.spi.ResourcePathTypeResolver; +import org.citrusframework.spi.TypeResolver; +import org.citrusframework.util.ReflectionHelper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.lang.reflect.Array; import java.lang.reflect.Field; import java.util.ArrayList; @@ -24,13 +33,7 @@ import java.util.List; import java.util.Map; -import org.citrusframework.context.TestContext; -import org.citrusframework.exceptions.CitrusRuntimeException; -import org.citrusframework.spi.ResourcePathTypeResolver; -import org.citrusframework.spi.TypeResolver; -import org.citrusframework.util.ReflectionHelper; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import static java.lang.String.format; /** * Simple registry holding all available segment variable extractor implementations. Test context can ask this registry for @@ -53,7 +56,6 @@ public class SegmentVariableExtractorRegistry { * Resolves extractor from resource path lookup with given extractor resource name. Scans classpath for extractor meta information * with given name and returns instance of extractor. Returns optional instead of throwing exception when no extractor * could be found. - * @return */ static Collection lookup() { try { @@ -69,7 +71,8 @@ static Collection lookup() { /** * SegmentVariableExtractors to extract values from value representations of individual segments. */ - private final List segmentValueExtractors = new ArrayList<>(List.of(MapVariableExtractor.INSTANCE, ObjectFieldValueExtractor.INSTANCE)); + private final List segmentValueExtractors = new ArrayList<>(List.of( + MapVariableExtractor.INSTANCE, ObjectFieldValueExtractor.INSTANCE)); public SegmentVariableExtractorRegistry() { segmentValueExtractors.addAll(lookup()); @@ -77,8 +80,7 @@ public SegmentVariableExtractorRegistry() { /** * Obtain the segment variable extractors managed by the registry - * - * @return + */ public List getSegmentValueExtractors() { return segmentValueExtractors; @@ -87,135 +89,197 @@ public List getSegmentValueExtractors() { /** * Base class for segment variable extractors that ensures that an exception is thrown upon no match. */ - public static abstract class AbstractSegmentVariableExtractor implements SegmentVariableExtractor { + public abstract static class AbstractSegmentVariableExtractor implements SegmentVariableExtractor { @Override public final Object extractValue(TestContext testContext, Object object, VariableExpressionSegmentMatcher matcher) { - Object matchedValue = doExtractValue(testContext, object, matcher); - if (matchedValue == null) { - handleMatchFailure(matcher); + try { + return doExtractValue(testContext, object, matcher); + } catch (SegmentEvaluationException e) { + throw createMatchFailureException(matcher, object, e); } - - return matchedValue; } /** - * Handles a match failure by throwing a CitrusException with an appropriate message - * @param matcher + * Builds a {@link CitrusRuntimeException} describing why a variable/segment could not be resolved. */ - private void handleMatchFailure(VariableExpressionSegmentMatcher matcher) { - String exceptionMessage; - if (matcher.getTotalSegmentCount() == 1) { - exceptionMessage = String.format("Unknown variable '%s'" , - matcher.getVariableExpression()); - } else { - if (matcher.getSegmentIndex() == 1) { - exceptionMessage = String.format("Unknown variable for first segment '%s' " + - "of variable expression '%s'", - matcher.getSegmentExpression(), matcher.getVariableExpression()); - } else { - exceptionMessage = String.format("Unknown segment-value for segment '%s' " + - "of variable expression '%s'", - matcher.getSegmentExpression(), matcher.getVariableExpression()); - } + private static CitrusRuntimeException createMatchFailureException(VariableExpressionSegmentMatcher matcher, Object object, SegmentEvaluationException cause) { + + String expr = nullSafe(matcher.getVariableExpression()); + String segment = nullSafe(matcher.getSegmentExpression()); + int idx = safeIndex(matcher.getSegmentIndex()); + int total = safeIndex(matcher.getTotalSegmentCount()); + + String objectType = (object == null) ? "null" : object.getClass().getName(); + + StringBuilder sb = new StringBuilder(256) + .append("Unable to extract value using expression '").append(expr).append("'!"); + + if (total > 1 && idx >= 1 && idx <= total) { + sb.append(" — failed at segment '").append(segment) + .append("' (").append(idx).append('/').append(total).append(')'); + } + + if (cause != null) { + sb.append(format("%nReason: %s.", + cause.getMessage() == null ? "" : cause.getMessage() + )); } - throw new CitrusRuntimeException(exceptionMessage); + + sb.append(format("%nFrom object (%s):%n%s", objectType, cause != null ? cause.getRenderedObject() : "")); + + return new CitrusRuntimeException(sb.toString()); } - protected abstract Object doExtractValue(TestContext testContext, Object object, VariableExpressionSegmentMatcher matcher); + protected abstract Object doExtractValue(TestContext testContext, Object object, VariableExpressionSegmentMatcher matcher) throws SegmentEvaluationException; + + private static String nullSafe(String s) { return s == null ? "" : s; } + + private static int safeIndex(int i) { return Math.max(0, i); } + } + + + + /** Minimal, safe rendering used as fallback (truncate huge payloads). */ + protected static String renderObjectMinimal(Object object) { + if (object == null) return "null"; + return String.valueOf(object); } /** - * Base class for extractors that can operate on indexed values. + * Base class for extractors that support an optional [index] on the segment. */ - public static abstract class IndexedSegmentVariableExtractor extends AbstractSegmentVariableExtractor { + public abstract static class IndexedSegmentVariableExtractor extends AbstractSegmentVariableExtractor { - public final Object doExtractValue(TestContext testContext, Object object, VariableExpressionSegmentMatcher matcher) { + @Override + public final Object doExtractValue(TestContext testContext, Object object, VariableExpressionSegmentMatcher matcher) + throws SegmentEvaluationException { Object extractedValue = doExtractIndexedValue(testContext, object, matcher); if (matcher.getSegmentIndex() != -1) { - extractedValue = getIndexedElement(matcher, extractedValue); + extractedValue = getIndexedElement(object, matcher, extractedValue); } return extractedValue; } /** - * Get the index element from an indexed value. - * - * @param matcher - * @param indexedValue - * @return + * Return the element at the given index from arrays or lists. Throw SegmentEvaluationException for errors. */ - private Object getIndexedElement(VariableExpressionSegmentMatcher matcher, Object indexedValue) { + private Object getIndexedElement(Object root, VariableExpressionSegmentMatcher matcher, Object indexedValue) + throws SegmentEvaluationException { + + int idx = matcher.getSegmentIndex(); + + if (indexedValue == null) { + throw new SegmentEvaluationException( + format("Cannot index into null for segment '%s' (index %d)", + matcher.getSegmentExpression(), idx), + renderObjectMinimal(root)); + } + + // Java array if (indexedValue.getClass().isArray()) { - return Array.get(indexedValue, matcher.getSegmentIndex()); - } else { - throw new CitrusRuntimeException( - String.format("Expected an instance of Array type. Cannot retrieve indexed property %s from %s ", - matcher.getSegmentExpression(), indexedValue.getClass().getName())); + int length = Array.getLength(indexedValue); + if (idx < 0 || idx >= length) { + throw new SegmentEvaluationException( + format("Index %d out of bounds (array length %d) for segment '%s'", + idx, length, matcher.getSegmentExpression()), + renderObjectMinimal(root)); + } + return Array.get(indexedValue, idx); + } + + // java.util.List + if (indexedValue instanceof List list) { + int length = list.size(); + if (idx < 0 || idx >= length) { + throw new SegmentEvaluationException( + format("Index %d out of bounds (list size %d) for segment '%s'", + idx, length, matcher.getSegmentExpression()), + renderObjectMinimal(root)); + } + return list.get(idx); } + + // Unsupported type + throw new SegmentEvaluationException( + format("Expected array or List for indexed access, but was %s (segment '%s')", + indexedValue.getClass().getName(), matcher.getSegmentExpression()), + renderObjectMinimal(root)); } - /** - * Extract the indexed value from the object - * - * @param object - * @param matcher - * @return - */ - protected abstract Object doExtractIndexedValue(TestContext testContext, Object object, VariableExpressionSegmentMatcher matcher); + /** Implement in subclasses: extract the (possibly indexed) container value to index into. */ + protected abstract Object doExtractIndexedValue(TestContext testContext, Object object, VariableExpressionSegmentMatcher matcher) + throws SegmentEvaluationException; } /** - * SegmentVariableExtractor that accesses the segment value by a {@link Field} of the parentObject + * Extracts a segment via a declared field on the parent object. */ public static class ObjectFieldValueExtractor extends IndexedSegmentVariableExtractor { - public static ObjectFieldValueExtractor INSTANCE = new ObjectFieldValueExtractor(); - - private ObjectFieldValueExtractor() { - // singleton - } + public static final ObjectFieldValueExtractor INSTANCE = new ObjectFieldValueExtractor(); + private ObjectFieldValueExtractor() {} @Override - protected Object doExtractIndexedValue(TestContext testContext, Object parentObject, VariableExpressionSegmentMatcher matcher) { - Field field = ReflectionHelper.findField(parentObject.getClass(), matcher.getSegmentExpression()); - if (field == null) { - throw new CitrusRuntimeException(String.format("Failed to get variable - unknown field '%s' on type %s", - matcher.getSegmentExpression(), parentObject.getClass().getName())); + protected Object doExtractIndexedValue(TestContext testContext, Object parentObject, VariableExpressionSegmentMatcher matcher) + throws SegmentEvaluationException { + try { + Field field = ReflectionHelper.findField(parentObject.getClass(), matcher.getSegmentExpression()); + if (field == null) { + throw new SegmentEvaluationException( + format("Unknown field '%s' on type %s", + matcher.getSegmentExpression(), parentObject.getClass().getName()), + renderObjectMinimal(parentObject)); + } + return ReflectionHelper.getField(field, parentObject); + } catch (SegmentEvaluationException see) { + throw see; // rethrow as-is + } catch (Exception ex) { + throw new SegmentEvaluationException( + format("Failed to access field '%s' on type %s: %s", + matcher.getSegmentExpression(), parentObject.getClass().getName(), ex.getMessage()), + renderObjectMinimal(parentObject)); } - - return ReflectionHelper.getField(field, parentObject); } @Override public boolean canExtract(TestContext testContext, Object object, VariableExpressionSegmentMatcher matcher) { + // Objects except Strings (JSON/XML strings handled by dedicated extractors) return object != null && !(object instanceof String); } } /** - * SegmentVariableExtractor that accesses the segment value from a {@link Map}. The extractor uses the segment expression - * as key into the map. + * Extracts a segment via Map lookup using the segment expression as key. */ public static class MapVariableExtractor extends IndexedSegmentVariableExtractor { - public static MapVariableExtractor INSTANCE = new MapVariableExtractor(); - - private MapVariableExtractor() { - // singleton - } + public static final MapVariableExtractor INSTANCE = new MapVariableExtractor(); + private MapVariableExtractor() {} @Override - protected Object doExtractIndexedValue(TestContext testContext, Object parentObject, VariableExpressionSegmentMatcher matcher) { + protected Object doExtractIndexedValue(TestContext testContext, Object parentObject, VariableExpressionSegmentMatcher matcher) + throws SegmentEvaluationException { + + if (!(parentObject instanceof Map map)) { + throw new SegmentEvaluationException( + format("Expected Map for segment '%s' but was %s", + matcher.getSegmentExpression(), parentObject == null ? "null" : parentObject.getClass().getName()), + renderObjectMinimal(parentObject)); + } - Object matchedValue = null; - if (parentObject instanceof Map) { - matchedValue = ((Map) parentObject).get(matcher.getSegmentExpression()); + String key = matcher.getSegmentExpression(); + if (!map.containsKey(key)) { + throw new SegmentEvaluationException( + format("Unknown key '%s' in Map", key), + renderObjectMinimal(parentObject)); } - return matchedValue; + + // Value may legitimately be null—return it as-is. + return map.get(key); } @Override @@ -223,4 +287,5 @@ public boolean canExtract(TestContext testContext, Object object, VariableExpres return object instanceof Map; } } + } diff --git a/core/citrus-api/src/test/java/org/citrusframework/variable/IndexedSegmentVariableExtractorsTest.java b/core/citrus-api/src/test/java/org/citrusframework/variable/IndexedSegmentVariableExtractorsTest.java new file mode 100644 index 0000000000..eee876b240 --- /dev/null +++ b/core/citrus-api/src/test/java/org/citrusframework/variable/IndexedSegmentVariableExtractorsTest.java @@ -0,0 +1,180 @@ +package org.citrusframework.variable; + +import org.citrusframework.context.TestContext; +import org.citrusframework.exceptions.CitrusRuntimeException; +import org.citrusframework.variable.SegmentVariableExtractorRegistry.MapVariableExtractor; +import org.citrusframework.variable.SegmentVariableExtractorRegistry.ObjectFieldValueExtractor; +import org.testng.annotations.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.STRING; + +public class IndexedSegmentVariableExtractorsTest { + + private final TestContext context = new TestContext(); + + private static VariableExpressionSegmentMatcher matcher(String segmentExpr) { + VariableExpressionSegmentMatcher m = new VariableExpressionSegmentMatcher(segmentExpr); + assertThat(m.nextMatch()).as("first segment should match").isTrue(); + return m; + } + + @Test + public void mapExtractor_listIndex_success() { + Map ctx = Map.of("names", List.of("A", "B", "C")); + var extractor = MapVariableExtractor.INSTANCE; + + var m = matcher("names[1]"); + + assertThat(extractor.canExtract(context, ctx, m)).isTrue(); + assertThat(extractor.extractValue(context, ctx, m)).isEqualTo("B"); + } + + @Test + public void mapExtractor_arrayIndex_success() { + Map ctx = Map.of("nums", new int[] {10, 20, 30}); + var extractor = MapVariableExtractor.INSTANCE; + + var m = matcher("nums[2]"); + + assertThat(extractor.canExtract(context, ctx, m)).isTrue(); + assertThat(extractor.extractValue(context, ctx, m)).isEqualTo(30); + } + + @Test + public void mapExtractor_arrayIndex_nullElement_success() { + Map ctx = Map.of("nums", new Integer[] {10, 20, null}); + var extractor = MapVariableExtractor.INSTANCE; + + var m = matcher("nums[2]"); + + assertThat(extractor.canExtract(context, ctx, m)).isTrue(); + assertThat(extractor.extractValue(context, ctx, m)).isNull(); + } + + @Test + public void mapExtractor_unknownKey_failsWithHelpfulMessage() { + Map ctx = Map.of("names", List.of("A", "B")); + var extractor = MapVariableExtractor.INSTANCE; + + var m = matcher("missing"); + + assertThatThrownBy(() -> extractor.extractValue(context, ctx, m)) + .isInstanceOf(CitrusRuntimeException.class) + .extracting("message", STRING) + .isEqualToIgnoringWhitespace(""" + Unable to extract value using expression 'missing'! + Reason: Unknown key 'missing' in Map. + From object (java.util.ImmutableCollections$Map1): + {names=[A, B]}"""); + } + + @Test + public void mapExtractor_indexOutOfBounds_failsWithSizeInfo() { + Map ctx = Map.of("names", List.of("A", "B", "C")); + var extractor = MapVariableExtractor.INSTANCE; + + var m = matcher("names[3]"); // OOB + + assertThatThrownBy(() -> extractor.extractValue(context, ctx, m)) + .isInstanceOf(CitrusRuntimeException.class) + .hasMessageContaining("Unable to extract value using expression 'names[3]'") + .hasMessageContaining("Index 3 out of bounds (list size 3) for segment 'names'"); + } + + @Test + public void mapExtractor_wrongTypeForIndexing_failsWithTypeInfo() { + Map ctx = Map.of("names", 42); // not list/array + var extractor = MapVariableExtractor.INSTANCE; + + var m = matcher("names[0]"); + + assertThatThrownBy(() -> extractor.extractValue(context, ctx, m)) + .isInstanceOf(CitrusRuntimeException.class) + .hasMessageContaining("Unable to extract value using expression 'names[0]'") + .hasMessageContaining("Expected array or List for indexed access, but was java.lang.Integer (segment 'names')"); + } + + public static class Person { + int[] numbers = {10, 20, 30}; + List tags = List.of("alpha", "beta"); + String name = "Peter"; + String nullField = null; + } + + @Test + public void objectFieldExtractor_arrayIndex_success() { + var person = new Person(); + var extractor = ObjectFieldValueExtractor.INSTANCE; + + var m = matcher("numbers[2]"); + + assertThat(extractor.canExtract(context, person, m)).isTrue(); + assertThat(extractor.extractValue(context, person, m)).isEqualTo(30); + } + + @Test + public void objectFieldExtractor_listIndex_success() { + var person = new Person(); + var extractor = ObjectFieldValueExtractor.INSTANCE; + + var m = matcher("tags[0]"); + + assertThat(extractor.canExtract(context, person, m)).isTrue(); + assertThat(extractor.extractValue(context, person, m)).isEqualTo("alpha"); + } + + @Test + public void objectFieldExtractor_nullValueFromField_success() { + var person = new Person(); + var extractor = ObjectFieldValueExtractor.INSTANCE; + + var m = matcher("nullField"); + + assertThat(extractor.canExtract(context, person, m)).isTrue(); + assertThat(extractor.extractValue(context, person, m)).isNull(); + } + + @Test + public void objectFieldExtractor_unknownField_failsWithHelpfulMessage() { + var person = new Person(); + var extractor = ObjectFieldValueExtractor.INSTANCE; + + var m = matcher("missing"); + + assertThatThrownBy(() -> extractor.extractValue(context, person, m)) + .isInstanceOf(CitrusRuntimeException.class) + .hasMessageContaining("Unable to extract value using expression 'missing'") + .hasMessageContaining("Reason: Unknown field 'missing' on type org.citrusframework.variable.IndexedSegmentVariableExtractorsTest$Person"); + } + + @Test + public void objectFieldExtractor_wrongTypeForIndexing_failsWithTypeInfo() { + var person = new Person(); // field 'name' is String + var extractor = ObjectFieldValueExtractor.INSTANCE; + + var m = matcher("name[0]"); + + assertThatThrownBy(() -> extractor.extractValue(context, person, m)) + .isInstanceOf(CitrusRuntimeException.class) + .hasMessageContaining("Unable to extract value using expression 'name[0]'") + .hasMessageContaining("Expected array or List for indexed access, but was java.lang.String (segment 'name')"); + } + + @Test + public void objectFieldExtractor_indexOutOfBounds_failsWithLengthInfo() { + var person = new Person(); + var extractor = ObjectFieldValueExtractor.INSTANCE; + + var m = matcher("numbers[9]"); + + assertThatThrownBy(() -> extractor.extractValue(context, person, m)) + .isInstanceOf(CitrusRuntimeException.class) + .hasMessageContaining("Unable to extract value using expression 'numbers[9]'") + .hasMessageContaining("Index 9 out of bounds (array length 3) for segment 'numbers'"); + } +} diff --git a/core/citrus-base/src/test/java/org/citrusframework/actions/LoadPropertiesActionTest.java b/core/citrus-base/src/test/java/org/citrusframework/actions/LoadPropertiesActionTest.java index 87540923a9..7eb92115a1 100644 --- a/core/citrus-base/src/test/java/org/citrusframework/actions/LoadPropertiesActionTest.java +++ b/core/citrus-base/src/test/java/org/citrusframework/actions/LoadPropertiesActionTest.java @@ -16,48 +16,50 @@ package org.citrusframework.actions; -import java.text.SimpleDateFormat; -import java.util.Date; - import org.citrusframework.UnitTestSupport; import org.citrusframework.exceptions.CitrusRuntimeException; import org.testng.Assert; import org.testng.annotations.Test; +import java.text.SimpleDateFormat; +import java.util.Date; + +import static java.lang.String.format; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + public class LoadPropertiesActionTest extends UnitTestSupport { - @Test - public void testLoadProperties() { - LoadPropertiesAction loadProperties = new LoadPropertiesAction.Builder() - .filePath("classpath:org/citrusframework/actions/load.properties") - .build(); + @Test + public void testLoadProperties() { + LoadPropertiesAction loadProperties = new LoadPropertiesAction.Builder() + .filePath("classpath:org/citrusframework/actions/load.properties") + .build(); - loadProperties.execute(context); + loadProperties.execute(context); - Assert.assertNotNull(context.getVariable("${myVariable}")); - Assert.assertEquals(context.getVariable("${myVariable}"), "test"); - Assert.assertNotNull(context.getVariable("${user}")); + Assert.assertNotNull(context.getVariable("${myVariable}")); + Assert.assertEquals(context.getVariable("${myVariable}"), "test"); + Assert.assertNotNull(context.getVariable("${user}")); Assert.assertEquals(context.getVariable("${user}"), "Citrus"); - Assert.assertNotNull(context.getVariable("${welcomeText}")); - Assert.assertEquals(context.getVariable("${welcomeText}"), "Hello Citrus!"); - Assert.assertNotNull(context.getVariable("${todayDate}")); + Assert.assertNotNull(context.getVariable("${welcomeText}")); + Assert.assertEquals(context.getVariable("${welcomeText}"), "Hello Citrus!"); + Assert.assertNotNull(context.getVariable("${todayDate}")); Assert.assertEquals(context.getVariable("${todayDate}"), "Today is " + new SimpleDateFormat("yyyy-MM-dd").format(new Date(System.currentTimeMillis())) + "!"); - } + } - @Test + @Test public void testUnknownVariableInLoadProperties() { - LoadPropertiesAction loadProperties = new LoadPropertiesAction.Builder() - .filePath("classpath:org/citrusframework/actions/load-error.properties") - .build(); - - try { - loadProperties.execute(context); - } catch(CitrusRuntimeException e) { - Assert.assertEquals(e.getMessage(), "Unknown variable 'unknownVar'"); - return; - } - - Assert.fail("Missing exception for unkown variable in property file"); - } + LoadPropertiesAction loadProperties = new LoadPropertiesAction.Builder() + .filePath("classpath:org/citrusframework/actions/load-error.properties") + .build(); + + assertThatThrownBy(() -> loadProperties.execute(context)) + .isInstanceOf(CitrusRuntimeException.class) + .hasMessage( + format( + "Unable to extract value using expression 'unknownVar'!%nReason: Unknown key 'unknownVar' in Map.%nFrom object (java.util.concurrent.ConcurrentHashMap):%n{}" + ) + ); + } } diff --git a/core/citrus-base/src/test/java/org/citrusframework/actions/ReceiveMessageActionTest.java b/core/citrus-base/src/test/java/org/citrusframework/actions/ReceiveMessageActionTest.java index 3abd7375d5..098358114d 100644 --- a/core/citrus-base/src/test/java/org/citrusframework/actions/ReceiveMessageActionTest.java +++ b/core/citrus-base/src/test/java/org/citrusframework/actions/ReceiveMessageActionTest.java @@ -64,7 +64,9 @@ import java.util.List; import java.util.Map; +import static java.lang.String.format; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.citrusframework.message.MessageType.JSON; import static org.citrusframework.message.MessageType.PLAINTEXT; import static org.citrusframework.message.MessageType.XHTML; @@ -526,11 +528,14 @@ public void testReceiveMessageWithUnknownVariablesInMessageHeaders() { .endpoint(endpoint) .message(controlMessageBuilder) .build(); - try { - receiveAction.execute(context); - } catch (CitrusRuntimeException e) { - Assert.assertEquals(e.getMessage(), "Unknown variable 'myOperation'"); - } + + assertThatThrownBy(() -> receiveAction.execute(context)) + .isInstanceOf(CitrusRuntimeException.class) + .hasMessage( + format( + "Unable to extract value using expression 'myOperation'!%nReason: Unknown key 'myOperation' in Map.%nFrom object (java.util.concurrent.ConcurrentHashMap):%n{}" + ) + ); } @Test @@ -563,11 +568,14 @@ public void testReceiveMessageWithUnknownVariableInMessagePayload() { .endpoint(endpoint) .message(controlMessageBuilder) .build(); - try { - receiveAction.execute(context); - } catch (CitrusRuntimeException e) { - Assert.assertEquals(e.getMessage(), "Unknown variable 'myText'"); - } + + assertThatThrownBy(() -> receiveAction.execute(context)) + .isInstanceOf(CitrusRuntimeException.class) + .hasMessage( + format( + "Unable to extract value using expression 'myText'!%nReason: Unknown key 'myText' in Map.%nFrom object (java.util.concurrent.ConcurrentHashMap):%n{}" + ) + ); } @Test diff --git a/core/citrus-base/src/test/java/org/citrusframework/actions/SendMessageActionTest.java b/core/citrus-base/src/test/java/org/citrusframework/actions/SendMessageActionTest.java index c5a5fbab2c..85cd0e7aef 100644 --- a/core/citrus-base/src/test/java/org/citrusframework/actions/SendMessageActionTest.java +++ b/core/citrus-base/src/test/java/org/citrusframework/actions/SendMessageActionTest.java @@ -16,12 +16,6 @@ package org.citrusframework.actions; -import java.io.UnsupportedEncodingException; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; - import org.citrusframework.DefaultTestCase; import org.citrusframework.TestActor; import org.citrusframework.TestCase; @@ -48,6 +42,14 @@ import org.testng.Assert; import org.testng.annotations.Test; +import java.io.UnsupportedEncodingException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static java.lang.String.format; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.any; import static org.mockito.Mockito.doAnswer; @@ -314,14 +316,14 @@ public void testSendMessageWithUnknownVariableInMessagePayload() { .endpoint(endpoint) .message(messageBuilder) .build(); - try { - sendAction.execute(context); - } catch(CitrusRuntimeException e) { - Assert.assertEquals(e.getMessage(), "Unknown variable 'myText'"); - return; - } - Assert.fail("Missing " + CitrusRuntimeException.class + " with unknown variable error message"); + assertThatThrownBy(() -> sendAction.execute(context)) + .isInstanceOf(CitrusRuntimeException.class) + .hasMessage( + format( + "Unable to extract value using expression 'myText'!%nReason: Unknown key 'myText' in Map.%nFrom object (java.util.concurrent.ConcurrentHashMap):%n{}" + ) + ); } @Test @@ -342,14 +344,14 @@ public void testSendMessageWithUnknownVariableInHeaders() { .endpoint(endpoint) .message(messageBuilder) .build(); - try { - sendAction.execute(context); - } catch(CitrusRuntimeException e) { - Assert.assertEquals(e.getMessage(), "Unknown variable 'myOperation'"); - return; - } - Assert.fail("Missing " + CitrusRuntimeException.class + " with unknown variable error message"); + assertThatThrownBy(() -> sendAction.execute(context)) + .isInstanceOf(CitrusRuntimeException.class) + .hasMessage( + format( + "Unable to extract value using expression 'myOperation'!%nReason: Unknown key 'myOperation' in Map.%nFrom object (java.util.concurrent.ConcurrentHashMap):%n{}" + ) + ); } @Test diff --git a/core/citrus-spring/src/test/java/org/citrusframework/variable/GlobalVariablesPropertyLoaderTest.java b/core/citrus-spring/src/test/java/org/citrusframework/variable/GlobalVariablesPropertyLoaderTest.java index d71f2f9f89..64f09695e8 100644 --- a/core/citrus-spring/src/test/java/org/citrusframework/variable/GlobalVariablesPropertyLoaderTest.java +++ b/core/citrus-spring/src/test/java/org/citrusframework/variable/GlobalVariablesPropertyLoaderTest.java @@ -16,15 +16,18 @@ package org.citrusframework.variable; -import java.text.SimpleDateFormat; -import java.util.Collections; -import java.util.Date; - import org.citrusframework.UnitTestSupport; import org.citrusframework.exceptions.CitrusRuntimeException; import org.testng.Assert; import org.testng.annotations.Test; +import java.text.SimpleDateFormat; +import java.util.Collections; +import java.util.Date; + +import static java.lang.String.format; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + public class GlobalVariablesPropertyLoaderTest extends UnitTestSupport { @Test @@ -119,14 +122,12 @@ public void testUnknownVariableDuringPropertyLoading() { propertyLoader.setGlobalVariables(globalVariables); propertyLoader.setFunctionRegistry(testContextFactory.getFunctionRegistry()); - try { - propertyLoader.afterPropertiesSet(); - } catch (CitrusRuntimeException e) { - Assert.assertTrue(globalVariables.getVariables().isEmpty()); - Assert.assertEquals(e.getMessage(), "Unknown variable 'unknownVar'"); - return; - } - - Assert.fail("Missing exception because of unknown variable in global variable property loader"); + assertThatThrownBy(propertyLoader::afterPropertiesSet) + .isInstanceOf(CitrusRuntimeException.class) + .hasMessage( + format( + "Unable to extract value using expression 'unknownVar'!%nReason: Unknown key 'unknownVar' in Map.%nFrom object (java.util.concurrent.ConcurrentHashMap):%n{}" + ) + ); } } diff --git a/runtime/citrus-testng/src/test/java/org/citrusframework/actions/dsl/AssertExceptionTestActionBuilderTest.java b/runtime/citrus-testng/src/test/java/org/citrusframework/actions/dsl/AssertExceptionTestActionBuilderTest.java index c44113fa3d..e48be38039 100644 --- a/runtime/citrus-testng/src/test/java/org/citrusframework/actions/dsl/AssertExceptionTestActionBuilderTest.java +++ b/runtime/citrus-testng/src/test/java/org/citrusframework/actions/dsl/AssertExceptionTestActionBuilderTest.java @@ -27,6 +27,7 @@ import org.citrusframework.exceptions.CitrusRuntimeException; import org.testng.annotations.Test; +import static java.lang.String.format; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertTrue; @@ -42,7 +43,7 @@ public void testAssertDefaultExceptionBuilder() { assertEquals(test.getActions().get(0).getClass(), Assert.class); assertEquals(test.getActions().get(0).getName(), "assert"); - Assert container = (Assert)(test.getTestAction(0)); + Assert container = (Assert) (test.getTestAction(0)); assertEquals(container.getActionCount(), 1); assertEquals(container.getAction().getClass(), FailAction.class); @@ -51,47 +52,55 @@ public void testAssertDefaultExceptionBuilder() { @Test public void testAssertBuilder() { + var expectedErrorMessage = format( + "Unable to extract value using expression 'foo'!%nReason: Unknown key 'foo' in Map.%nFrom object (java.util.concurrent.ConcurrentHashMap):%n{}" + ); + DefaultTestCaseRunner builder = new DefaultTestCaseRunner(context); builder.$(assertException().exception(CitrusRuntimeException.class) - .message("Unknown variable 'foo'") - .when(echo("${foo}"))); + .message(expectedErrorMessage) + .when(echo("${foo}"))); TestCase test = builder.getTestCase(); assertEquals(test.getActionCount(), 1); assertEquals(test.getActions().get(0).getClass(), Assert.class); assertEquals(test.getActions().get(0).getName(), "assert"); - Assert container = (Assert)(test.getTestAction(0)); + Assert container = (Assert) (test.getTestAction(0)); assertEquals(container.getActionCount(), 1); assertEquals(container.getAction().getClass(), EchoAction.class); assertEquals(container.getException(), CitrusRuntimeException.class); - assertEquals(container.getMessage(), "Unknown variable 'foo'"); - assertEquals(((EchoAction)(container.getAction())).getMessage(), "${foo}"); + assertEquals(container.getMessage(), expectedErrorMessage); + assertEquals(((EchoAction) (container.getAction())).getMessage(), "${foo}"); } @Test public void testAssertBuilderWithAnonymousAction() { + var expectedErrorMessage = format( + "Unable to extract value using expression 'foo'!%nReason: Unknown key 'foo' in Map.%nFrom object (java.util.concurrent.ConcurrentHashMap):%n{}" + ); + DefaultTestCaseRunner builder = new DefaultTestCaseRunner(context); builder.$(assertException().exception(CitrusRuntimeException.class) - .message("Unknown variable 'foo'") - .when(new AbstractTestAction() { - @Override - public void doExecute(TestContext context) { - context.getVariable("foo"); - } - })); + .message(expectedErrorMessage) + .when(new AbstractTestAction() { + @Override + public void doExecute(TestContext context) { + context.getVariable("foo"); + } + })); TestCase test = builder.getTestCase(); assertEquals(test.getActionCount(), 1); assertEquals(test.getActions().get(0).getClass(), Assert.class); assertEquals(test.getActions().get(0).getName(), "assert"); - Assert container = (Assert)(test.getTestAction(0)); + Assert container = (Assert) (test.getTestAction(0)); assertEquals(container.getActionCount(), 1); assertTrue(container.getAction().getClass().isAnonymousClass()); assertEquals(container.getException(), CitrusRuntimeException.class); - assertEquals(container.getMessage(), "Unknown variable 'foo'"); + assertEquals(container.getMessage(), expectedErrorMessage); } } diff --git a/validation/citrus-validation-json/src/main/java/org/citrusframework/json/JsonPathSegmentVariableExtractor.java b/validation/citrus-validation-json/src/main/java/org/citrusframework/json/JsonPathSegmentVariableExtractor.java index 5111007448..532e16e011 100644 --- a/validation/citrus-validation-json/src/main/java/org/citrusframework/json/JsonPathSegmentVariableExtractor.java +++ b/validation/citrus-validation-json/src/main/java/org/citrusframework/json/JsonPathSegmentVariableExtractor.java @@ -16,31 +16,81 @@ package org.citrusframework.json; -import com.jayway.jsonpath.InvalidPathException; +import com.fasterxml.jackson.databind.ObjectMapper; import org.citrusframework.context.TestContext; -import org.citrusframework.exceptions.CitrusRuntimeException; +import org.citrusframework.exceptions.SegmentEvaluationException; import org.citrusframework.util.IsJsonPredicate; import org.citrusframework.validation.json.JsonPathMessageValidationContext; import org.citrusframework.variable.SegmentVariableExtractorRegistry; import org.citrusframework.variable.VariableExpressionSegmentMatcher; -public class JsonPathSegmentVariableExtractor extends SegmentVariableExtractorRegistry.AbstractSegmentVariableExtractor { +public class JsonPathSegmentVariableExtractor extends + SegmentVariableExtractorRegistry.AbstractSegmentVariableExtractor { + + private static final ObjectMapper MAPPER = new ObjectMapper(); @Override - public boolean canExtract(TestContext testContext, Object object, VariableExpressionSegmentMatcher matcher) { - return object == null || (object instanceof String && IsJsonPredicate.getInstance().test((String)object) && JsonPathMessageValidationContext.isJsonPathExpression(matcher.getSegmentExpression())); + public boolean canExtract(TestContext testContext, Object object, + VariableExpressionSegmentMatcher matcher) { + return object == null || (object instanceof String string && IsJsonPredicate.getInstance() + .test(string) && JsonPathMessageValidationContext.isJsonPathExpression( + matcher.getSegmentExpression())); } @Override - public Object doExtractValue(TestContext testContext, Object object, VariableExpressionSegmentMatcher matcher) { - return object == null ? null : extractJsonPath(object.toString(), matcher.getSegmentExpression()); + public Object doExtractValue(TestContext testContext, Object object, + VariableExpressionSegmentMatcher matcher) throws SegmentEvaluationException { + try { + return object == null ? null + : extractJsonPath(object.toString(), matcher.getSegmentExpression()); + } catch (Exception e) { + StringBuilder messageBuilder = new StringBuilder(); + messageBuilder.append(e.getMessage()); + if (e.getCause() != e) { + messageBuilder.append("/"); + messageBuilder.append(e.getCause().getMessage()); + } + throw new SegmentEvaluationException(messageBuilder.toString(), renderObject(object)); + } } - private Object extractJsonPath(String json, String segmentExpression) { + private static String renderObject(Object object) { + if (object == null) { + return "null"; + } try { - return JsonPathUtils.evaluate(json, segmentExpression); - } catch (InvalidPathException e) { - throw new CitrusRuntimeException(String.format("Unable to extract jsonPath from segmentExpression %s", segmentExpression), e); + if (object instanceof CharSequence cs) { + String string = cs.toString(); + if (looksLikeJson(string)) { + return prettyJson(string); + } + return string; + } + if (object instanceof java.util.Map || object instanceof java.util.Collection) { + return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(object); + } + } catch (Exception ignore) { + /* fall back to toString */ } + + return String.valueOf(object); + } + + private static boolean looksLikeJson(String s) { + String t = s.stripLeading(); + return t.startsWith("{") || t.startsWith("["); + } + + private static String prettyJson(String json) { + try { + return MAPPER.writerWithDefaultPrettyPrinter() + .writeValueAsString(MAPPER.readTree(json)); + } catch (Exception e) { + return json; + } + } + + private Object extractJsonPath(String json, String segmentExpression) { + return JsonPathUtils.evaluate(json, segmentExpression); } } diff --git a/validation/citrus-validation-json/src/test/java/org/citrusframework/json/JsonPathSegmentVariableExtractorTest.java b/validation/citrus-validation-json/src/test/java/org/citrusframework/json/JsonPathSegmentVariableExtractorTest.java index 162a981e73..0c6c2a6709 100644 --- a/validation/citrus-validation-json/src/test/java/org/citrusframework/json/JsonPathSegmentVariableExtractorTest.java +++ b/validation/citrus-validation-json/src/test/java/org/citrusframework/json/JsonPathSegmentVariableExtractorTest.java @@ -17,24 +17,115 @@ package org.citrusframework.json; import org.citrusframework.UnitTestSupport; +import org.citrusframework.exceptions.CitrusRuntimeException; import org.citrusframework.variable.VariableExpressionSegmentMatcher; -import org.testng.Assert; import org.testng.annotations.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.STRING; + public class JsonPathSegmentVariableExtractorTest extends UnitTestSupport { - public static final String JSON_FIXTURE = "{\"name\": \"Peter\"}"; + public static final String JSON_FIXTURE = """ + { + "name": "Peter", + "married": true, + "wife": { + "name": "Linda", + "married": true, + "pets": null + }, + "children": [ + { + "name": "Paul", + "married": true, + "pets": null + }, + { + "name": "Laura", + "married": false, + "pets": null + } + ], + "pets": null + }"""; private final JsonPathSegmentVariableExtractor unitUnderTest = new JsonPathSegmentVariableExtractor(); @Test - public void testExtractFromJson() { + public void succeedToExtractExistingFromJson() { String jsonPath = "$.name"; VariableExpressionSegmentMatcher matcher = matchSegmentExpressionMatcher(jsonPath); - Assert.assertTrue(unitUnderTest.canExtract(context, JSON_FIXTURE, matcher)); - Assert.assertEquals(unitUnderTest.extractValue(context, JSON_FIXTURE, matcher), "Peter"); + assertThat(unitUnderTest.canExtract(context, JSON_FIXTURE, matcher)).isTrue(); + assertThat(unitUnderTest.extractValue(context, JSON_FIXTURE, matcher)).isEqualTo("Peter"); + } + + @Test + public void succeedToExtractExistingFromJsonArray() { + + String jsonPath = "$.children[1].name"; + VariableExpressionSegmentMatcher matcher = matchSegmentExpressionMatcher(jsonPath); + + assertThat(unitUnderTest.canExtract(context, JSON_FIXTURE, matcher)).isTrue(); + assertThat(unitUnderTest.extractValue(context, JSON_FIXTURE, matcher)).isEqualTo("Laura"); + } + + @Test + public void succeedToExtractNullFromExistingJsonArrayElement() { + + String jsonPath = "$.children[1].pets"; + VariableExpressionSegmentMatcher matcher = matchSegmentExpressionMatcher(jsonPath); + + assertThat(unitUnderTest.canExtract(context, JSON_FIXTURE, matcher)).isTrue(); + assertThat(unitUnderTest.extractValue(context, JSON_FIXTURE, matcher)).isNull(); + } + + @Test + public void failsToExtractNonExistingPath() { + + String jsonPath = "$.wife.sex"; + VariableExpressionSegmentMatcher matcher = matchSegmentExpressionMatcher(jsonPath); + + assertThatThrownBy(() -> unitUnderTest.extractValue(context, JSON_FIXTURE, matcher)) + .isInstanceOf(CitrusRuntimeException.class) + .extracting(Throwable::getMessage, STRING) + .isEqualToNormalizingNewlines(""" + Unable to extract value using expression 'jsonPath($.wife.sex)' + Reason: Failed to evaluate JSON path expression: $.wife.sex/No results for path: $['wife']['sex'] + From object (java.lang.String): + { + "name" : "Peter", + "married" : true, + "wife" : { + "name" : "Linda", + "married" : true, + "pets" : null + }, + "children" : [ { + "name" : "Paul", + "married" : true, + "pets" : null + }, { + "name" : "Laura", + "married" : false, + "pets" : null + } ], + "pets" : null + }"""); + + } + + @Test + public void testExtractNullFromJson() { + + String jsonPath = "$.pets"; + VariableExpressionSegmentMatcher matcher = matchSegmentExpressionMatcher(jsonPath); + + assertThat(unitUnderTest.canExtract(context, JSON_FIXTURE, matcher)).isTrue(); + assertThat(unitUnderTest.extractValue(context, JSON_FIXTURE, matcher)).isNull(); } @Test @@ -44,29 +135,29 @@ public void testExtractFromNonJsonPathExpression() { String nonJsonPath = "name"; VariableExpressionSegmentMatcher matcher = matchSegmentExpressionMatcher(nonJsonPath); - Assert.assertFalse(unitUnderTest.canExtract(context, json, matcher)); + assertThat(unitUnderTest.canExtract(context, json, matcher)).isFalse(); } @Test - public void testExtractFromJsonExpressionFailure() { + public void throwOnInvalidJsonPathExpression() { String json = "{\"name\": \"Peter\"}"; String invalidJsonPath = "$.$$$name"; VariableExpressionSegmentMatcher matcher = matchSegmentExpressionMatcher(invalidJsonPath); - Assert.assertTrue(unitUnderTest.canExtract(context, json, matcher)); - Assert.assertThrows(() -> unitUnderTest.extractValue(context, json, matcher)); + assertThat(unitUnderTest.canExtract(context, json, matcher)).isTrue(); + assertThatThrownBy(() -> unitUnderTest.extractValue(context, json, matcher)).isInstanceOf( + CitrusRuntimeException.class); } /** - * Create a variable expression jsonPath matcher and match the first jsonPath - * @param jsonPath - * @return + * Create a variable expression jsonPath matcher and match the jsonPath */ private VariableExpressionSegmentMatcher matchSegmentExpressionMatcher(String jsonPath) { String variableExpression = String.format("jsonPath(%s)", jsonPath); - VariableExpressionSegmentMatcher matcher = new VariableExpressionSegmentMatcher(variableExpression); - Assert.assertTrue(matcher.nextMatch()); + VariableExpressionSegmentMatcher matcher = new VariableExpressionSegmentMatcher( + variableExpression); + assertThat(matcher.nextMatch()).isTrue(); return matcher; } } diff --git a/validation/citrus-validation-json/src/test/java/org/citrusframework/json/JsonPathUtilsTest.java b/validation/citrus-validation-json/src/test/java/org/citrusframework/json/JsonPathUtilsTest.java new file mode 100644 index 0000000000..b1766637cc --- /dev/null +++ b/validation/citrus-validation-json/src/test/java/org/citrusframework/json/JsonPathUtilsTest.java @@ -0,0 +1,102 @@ +package org.citrusframework.json; + +import org.testng.annotations.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class JsonPathUtilsTest { + + @Test + public void testExtractNullValue() { + String json = """ + { + "animal": { + "name": "Garfield", + "type": "Cat", + "owner": null + } + } + """; + + String name = JsonPathUtils.evaluateAsString(json, "$.animal.name"); + assertThat(name).isEqualTo("Garfield"); + String owner = JsonPathUtils.evaluateAsString(json, "$.animal.owner"); + assertThat(owner).isNull(); + + } + + @Test + public void testExtractNullValue2() { + String json = """ + { + "person": { + "inLiquidation": false, + "kurzbezeichnungIstFreitext": true, + "segmentNachKundenwertigkeit": null, + "sperrgrund": null, + "formularKBildId": null, + "istGesperrtBis": null, + "sitzgesellschaftDatum": null, + "sperrgrundBemerkung": null, + "anzahlMitarbeiterQuelle": "KUNDE", + "mwstCodeManuell": false, + "meldungBund": false, + "betreuungsegment": "GK_1N", + "bezeichnung": "NEC - Nippon Electric Company", + "betriebsgroesse": "GU", + "holdingNummer": null, + "sprache": "DE", + "personTyp": "RECHTSGEMEINSCHAFT", + "kontrollinhaberAngaben": "IN_ABKLAERUNG", + "mwstNummer": null, + "istInteressentVon": null, + "kurzbezeichnung": "20250530NEC - Nippon Electric", + "istGesperrt": false, + "risikobrancheZuteilung": null, + "potentialscore": null, + "risikobrancheDatum": "2025-05-30", + "mwstCode": "Z", + "lifeCycleStatus": "AKTIV", + "jahresumsatz": null, + "status": "AKTIV", + "statusDatum": "2025-05-10", + "erfassungsDatum": "2025-05-10", + "sitzgesellschaft": false, + "letzterKontakt": null, + "rechtsform": "AG", + "gdoVersion": "20250530220211212", + "angebotFuer": null, + "teledatakey": null, + "unternehmenID": null, + "istInteressent": false, + "lifeCycleStatusDatum": "2025-05-30T22:02:11.200", + "formularSBildId": null, + "istHolding": false, + "anzahlMitarbeiterDatum": "2025-05-30", + "personOID": "9000000000000044028", + "risikobranche": "KEINE_RISIKOBRANCHE_AUTOM_ERM", + "istGesperrtVon": null, + "wirtschaftlicherZweck": "IRRELEVANT", + "personNummer": 1000411954, + "rentabilitaet": null, + "istInteressentBis": null, + "anzahlMitarbeiter": 5330946, + "branchencode": "949200", + "kontrollinhaberAngabenGrund": "LEER" + }, + "adresse": { + "ort": "Berikon", + "strasse": "Im Unterzelg", + "hausnummer": "777", + "land": "CH", + "plz": "8965" + }, + "hinweiseResponse": [] + } + """; + + Object name = JsonPathUtils.evaluate(json, "$.person.teledatakey"); + assertThat(name).isNull(); + + } +} diff --git a/validation/citrus-validation-xml/src/main/java/org/citrusframework/xml/XpathSegmentVariableExtractor.java b/validation/citrus-validation-xml/src/main/java/org/citrusframework/xml/XpathSegmentVariableExtractor.java index f4c89365d5..0b0a5f5db9 100644 --- a/validation/citrus-validation-xml/src/main/java/org/citrusframework/xml/XpathSegmentVariableExtractor.java +++ b/validation/citrus-validation-xml/src/main/java/org/citrusframework/xml/XpathSegmentVariableExtractor.java @@ -16,13 +16,20 @@ package org.citrusframework.xml; +import java.io.StringReader; +import java.io.StringWriter; import java.util.Collections; import java.util.UUID; import javax.xml.namespace.NamespaceContext; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; import org.citrusframework.XmlValidationHelper; import org.citrusframework.context.TestContext; import org.citrusframework.exceptions.CitrusRuntimeException; +import org.citrusframework.exceptions.SegmentEvaluationException; import org.citrusframework.message.DefaultMessage; import org.citrusframework.util.IsXmlPredicate; import org.citrusframework.util.XMLUtils; @@ -30,48 +37,101 @@ import org.citrusframework.variable.VariableExpressionSegmentMatcher; import org.citrusframework.xml.xpath.XPathExpressionResult; import org.citrusframework.xml.xpath.XPathUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.w3c.dom.Document; +import org.xml.sax.InputSource; -public class XpathSegmentVariableExtractor extends SegmentVariableExtractorRegistry.AbstractSegmentVariableExtractor { - - /** - * Logger - */ - private static final Logger logger = LoggerFactory.getLogger(XpathSegmentVariableExtractor.class); +public class XpathSegmentVariableExtractor extends + SegmentVariableExtractorRegistry.AbstractSegmentVariableExtractor { @Override - public boolean canExtract(TestContext testContext, Object object, VariableExpressionSegmentMatcher matcher) { - return object == null || (object instanceof Document - || (object instanceof String && IsXmlPredicate.getInstance().test((String)object)) - && XPathUtils.isXPathExpression(matcher.getSegmentExpression())); + public boolean canExtract(TestContext testContext, Object object, + VariableExpressionSegmentMatcher matcher) { + return object == null || (object instanceof Document + || (object instanceof String string && IsXmlPredicate.getInstance().test(string)) + && XPathUtils.isXPathExpression(matcher.getSegmentExpression())); } @Override - public Object doExtractValue(TestContext testContext, Object object, VariableExpressionSegmentMatcher matcher) { - return object == null ? null : extractXpath(testContext, object, matcher); + public Object doExtractValue(TestContext testContext, Object object, + VariableExpressionSegmentMatcher matcher) + throws SegmentEvaluationException { + try { + return (object == null) + ? null + : extractXpath(testContext, object, matcher); + } catch (Exception e) { + throw new SegmentEvaluationException(e.getMessage(), renderObject(object)); + } } - private Object extractXpath(TestContext testContext, Object xml, VariableExpressionSegmentMatcher matcher) { + private Object extractXpath(TestContext testContext, Object xml, + VariableExpressionSegmentMatcher matcher) { Document document = null; - if (xml instanceof Document) { + if (xml instanceof Document) { document = (Document) xml; - } else if (xml instanceof String) { - String documentCacheKey = UUID.nameUUIDFromBytes(((String)xml).getBytes()).toString(); - document = (Document)testContext.getVariables().get(documentCacheKey); + } else if (xml instanceof String string) { + String documentCacheKey = UUID.nameUUIDFromBytes(string.getBytes()).toString(); + document = (Document) testContext.getVariables().get(documentCacheKey); if (document == null) { - document = XMLUtils.parseMessagePayload((String)xml); + document = XMLUtils.parseMessagePayload(string); testContext.setVariable(documentCacheKey, document); } } if (document == null) { - throw new CitrusRuntimeException(String.format("Unable to extract xpath from object of type %s", xml.getClass())); + throw new CitrusRuntimeException( + String.format("Unable to extract xpath from object of type %s", xml.getClass())); } - NamespaceContext namespaceContext = XmlValidationHelper.getNamespaceContextBuilder(testContext).buildContext(new DefaultMessage().setPayload(xml), Collections.emptyMap()); - return XPathUtils.evaluate(document, matcher.getSegmentExpression(), namespaceContext, XPathExpressionResult.STRING); + NamespaceContext namespaceContext = XmlValidationHelper.getNamespaceContextBuilder( + testContext).buildContext(new DefaultMessage().setPayload(xml), Collections.emptyMap()); + return XPathUtils.evaluate(document, matcher.getSegmentExpression(), namespaceContext, + XPathExpressionResult.STRING); + } + + private static String renderObject(Object object) { + if (object == null) { + return "null"; + } + return prettyXml(String.valueOf(object)); + } + + private static String prettyXml(String s) { + try { + var dbf = DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(true); + var doc = dbf.newDocumentBuilder().parse(new InputSource(new StringReader(s))); + + var xslt = """ + + + + + + + + + + """; + + var tf = TransformerFactory.newInstance(); + var t = tf.newTransformer( + new javax.xml.transform.stream.StreamSource(new StringReader(xslt))); + + try { + t.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2"); + } catch (Exception ignored) { + // fall through + } + + var w = new StringWriter(); + t.transform(new DOMSource(doc), new StreamResult(w)); + return w.toString(); + } catch (Exception e) { + return s; // fallback + } } -} + +} \ No newline at end of file diff --git a/validation/citrus-validation-xml/src/test/java/org/citrusframework/xml/XmlPathSegmentVariableExtractorTest.java b/validation/citrus-validation-xml/src/test/java/org/citrusframework/xml/XmlPathSegmentVariableExtractorTest.java index 5460b5c343..323889560f 100644 --- a/validation/citrus-validation-xml/src/test/java/org/citrusframework/xml/XmlPathSegmentVariableExtractorTest.java +++ b/validation/citrus-validation-xml/src/test/java/org/citrusframework/xml/XmlPathSegmentVariableExtractorTest.java @@ -17,6 +17,7 @@ package org.citrusframework.xml; import org.citrusframework.UnitTestSupport; +import org.citrusframework.exceptions.CitrusRuntimeException; import org.citrusframework.variable.VariableExpressionSegmentMatcher; import org.testng.Assert; import org.testng.annotations.Test; @@ -24,9 +25,37 @@ import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.STRING; + public class XmlPathSegmentVariableExtractorTest extends UnitTestSupport { - private static final String XML_FIXTURE = "Peter"; + private static final String XML_FIXTURE = """ + + + Peter + true + + Linda + true + + + + + Paul + true + + + + Laura + false + + + + + + """; private final XpathSegmentVariableExtractor unitUnderTest = new XpathSegmentVariableExtractor(); @@ -76,8 +105,6 @@ public void testExtractFromXmlExpressionFailure() { /** * Create a variable expression xpath matcher and match the first xpath - * @param xpath - * @return */ private VariableExpressionSegmentMatcher matchSegmentExpressionMatcher(String xpath) { String variableExpression = String.format("xpath(%s)", xpath); @@ -85,4 +112,52 @@ private VariableExpressionSegmentMatcher matchSegmentExpressionMatcher(String xp Assert.assertTrue(matcher.nextMatch()); return matcher; } + + @Test + public void testExtractNullFromXml() { + + String jsonPath = "//person/pets"; + VariableExpressionSegmentMatcher matcher = matchSegmentExpressionMatcher(jsonPath); + + assertThat(unitUnderTest.canExtract(context, XML_FIXTURE, matcher)).isTrue(); + assertThat(unitUnderTest.extractValue(context, XML_FIXTURE, matcher)).isEqualTo(""); + } + @Test + public void failsToExtractNonExistingPath() { + + String jsonPath = "//person/wife/sex"; + VariableExpressionSegmentMatcher matcher = matchSegmentExpressionMatcher(jsonPath); + + assertThatThrownBy(() -> unitUnderTest.extractValue(context, XML_FIXTURE, matcher)) + .isInstanceOf(CitrusRuntimeException.class) + .extracting(Throwable::getMessage, STRING) + .isEqualToIgnoringWhitespace(""" + Unable to extract value using expression 'xpath(//person/wife/sex)' + Reason: No result for XPath expression: '//person/wife/sex' + From object (java.lang.String): + + Peter + true + + Linda + true + + + + + Paul + true + + + + Laura + false + + + + + + """); + + } }