diff --git a/ebean-test/src/main/java/io/ebean/test/CompareResult.java b/ebean-test/src/main/java/io/ebean/test/CompareResult.java new file mode 100644 index 000000000..1f2602674 --- /dev/null +++ b/ebean-test/src/main/java/io/ebean/test/CompareResult.java @@ -0,0 +1,38 @@ +package io.ebean.test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class CompareResult { + private final boolean applicable; + private final List errors; + + public static final CompareResult NO_ERRORS = new CompareResult(true, Collections.emptyList()); + public static final CompareResult NOT_APPLICABLE = new CompareResult(false, Collections.emptyList()); + + public static CompareResult error(String error) { + return new CompareResult(true, Collections.singletonList(error)); + } + + public static CompareResult errors(List errors) { + return new CompareResult(true, errors); + } + + private CompareResult(boolean applicable, List errors) { + this.applicable = applicable; + this.errors = new ArrayList<>(errors); + } + + public boolean isApplicable() { + return applicable; + } + + public boolean hasErrors() { + return !errors.isEmpty(); + } + + public List getErrors() { + return errors; + } +} diff --git a/ebean-test/src/main/java/io/ebean/test/JsonAssertContains.java b/ebean-test/src/main/java/io/ebean/test/JsonAssertContains.java index 25e1e065a..2c3082605 100644 --- a/ebean-test/src/main/java/io/ebean/test/JsonAssertContains.java +++ b/ebean-test/src/main/java/io/ebean/test/JsonAssertContains.java @@ -3,28 +3,26 @@ package io.ebean.test; import com.fasterxml.jackson.databind.JsonNode; import org.assertj.core.api.Assertions; -import java.util.Iterator; -import java.util.LinkedList; -import java.util.Map; -import java.util.Stack; +import java.util.*; +import java.util.stream.Collectors; /** * Perform traversal of JsonNodes comparing against an expected JsonNode that * typically contains a subset of the data (typically excludes any generated properties * like when modified timestamps etc). */ -class JsonAssertContains { +public class JsonAssertContains { private final Stack path = new Stack<>(); - private final LinkedList errors = new LinkedList<>(); static void assertContains(JsonNode actualJsonNode, JsonNode expectedJsonNode) { new JsonAssertContains().contains(actualJsonNode, expectedJsonNode); } private void contains(JsonNode actualJsonNode, JsonNode expectedJsonNode) { - checkRecursive(null, actualJsonNode, expectedJsonNode); - if (!errors.isEmpty()) { + CompareResult result = checkRecursive(null, actualJsonNode, expectedJsonNode); + if (result.hasErrors()) { + List errors = result.getErrors(); String errorsString = String.join("\n", errors); errorsString += "\nExpected JSON fields: " + expectedJsonNode; errorsString += "\nActual JSON: " + actualJsonNode; @@ -32,55 +30,128 @@ class JsonAssertContains { } } - private void checkRecursive(String name, JsonNode actualJsonNode, JsonNode expectedJsonNode) { + private CompareResult checkRecursive(String name, JsonNode actualJsonNode, JsonNode expectedJsonNode) { if (name != null) { path.push(name); } - if (checkNull(actualJsonNode, expectedJsonNode)) { - if (checkType(actualJsonNode, expectedJsonNode)) { - if (checkArray(actualJsonNode, expectedJsonNode)) { - if (checkObject(actualJsonNode, expectedJsonNode)) { - checkValue(actualJsonNode, expectedJsonNode); - } - } - } + + CompareResult result = checkNull(actualJsonNode, expectedJsonNode); + if (result.isApplicable()) { + return pop(name, result); } + + result = checkType(actualJsonNode, expectedJsonNode); + if (result.isApplicable()) { + return pop(name, result); + } + + result = checkArray(actualJsonNode, expectedJsonNode); + if (result.isApplicable()) { + return pop(name, result); + } + + result = checkObject(actualJsonNode, expectedJsonNode); + if (result.isApplicable()) { + return pop(name, result); + } + + result = checkValue(actualJsonNode, expectedJsonNode); + if (result.isApplicable()) { + return pop(name, result); + } + + return CompareResult.NOT_APPLICABLE; + } + + private CompareResult pop(String name, CompareResult result) { if (name != null) { path.pop(); } + return result; } - private boolean checkNull(JsonNode actualJsonNode, JsonNode expectedJsonNode) { + private CompareResult checkNull(JsonNode actualJsonNode, JsonNode expectedJsonNode) { if (actualJsonNode == null) { - errors.add(String.format("Expected field '%s' to be '%s' but was null", path(), expectedJsonNode)); - return false; + return CompareResult.error(String.format("Expected field '%s' to be '%s' but was null", path(), expectedJsonNode)); } - return true; + return CompareResult.NOT_APPLICABLE; } - private boolean checkType(JsonNode actualJsonNode, JsonNode expectedJsonNode) { + private CompareResult checkType(JsonNode actualJsonNode, JsonNode expectedJsonNode) { if (!expectedJsonNode.getNodeType().equals(actualJsonNode.getNodeType())) { - errors.add(String.format("Expected field '%s' to be of type '%s' but was '%s'", path(), expectedJsonNode.getNodeType(), actualJsonNode.getNodeType())); - return false; + return CompareResult.error(String.format("Expected field '%s' to be of type '%s' but was '%s'", path(), expectedJsonNode.getNodeType(), actualJsonNode.getNodeType())); } - return true; + return CompareResult.NOT_APPLICABLE; } - private boolean checkArray(JsonNode actualJsonNode, JsonNode expectedJsonNode) { + private CompareResult checkArray(JsonNode actualJsonNode, JsonNode expectedJsonNode) { if (!expectedJsonNode.isArray()) { - return true; + return CompareResult.NOT_APPLICABLE; } - for (int i = 0; i < expectedJsonNode.size(); i++) { - checkRecursive("[" + i + "]", actualJsonNode.get(i), expectedJsonNode.get(i)); + + Map> matchingIndexes = findMatchingIndexes(actualJsonNode, expectedJsonNode); + List unmatchedIndexes = listUnmatchedIndexes(expectedJsonNode.size(), matchingIndexes); + List>> remainingEntries = removeMultipleMatches(matchingIndexes); + if (!remainingEntries.isEmpty()) { + unmatchedIndexes.addAll(remainingEntries.stream().map(Map.Entry::getKey).collect(Collectors.toList())); } - // do not continue (object or scalar type check) - return false; + + List errors = unmatchedIndexes.stream() + .map(index -> String.format("Unable to match expected element '%s[%d]' in the actual array", path(), index)) + .collect(Collectors.toList()); + + return CompareResult.errors(errors); } - private boolean checkObject(JsonNode actualJsonNode, JsonNode expectedJsonNode) { - if (!expectedJsonNode.isObject()) { - return true; + private List listUnmatchedIndexes(int size, Map> matchingIndexes) { + List unmatched = new LinkedList<>(); + for (int i = 0; i < size; i++) { + if (!matchingIndexes.containsKey(i)) { + unmatched.add(i); + } } + return unmatched; + } + + private List>> removeMultipleMatches(Map> matchingIndexes) { + List>> entries = new ArrayList<>(matchingIndexes.entrySet()); + entries.sort(Comparator.comparingInt(entry -> entry.getValue().size())); + ListIterator>> iterator = entries.listIterator(); + + while (iterator.hasNext()) { + Map.Entry> next = iterator.next(); + if (!next.getValue().isEmpty()) { + iterator.remove(); + Integer aMatchingIndex = next.getValue().stream().findFirst().get(); + removeAllMatchingIndexesOf(aMatchingIndex, entries); + } + } + + return entries; + } + + private void removeAllMatchingIndexesOf(Integer aMatchingIndex, List>> matchingIndexes) { + matchingIndexes.forEach(entry -> entry.getValue().remove(aMatchingIndex)); + } + + private Map> findMatchingIndexes(JsonNode actualJsonNode, JsonNode expectedJsonNode) { + Map> matchingElementsIndexes = new HashMap<>(); + for (int e = 0; e < expectedJsonNode.size(); e++) { + for (int a = 0; a < actualJsonNode.size(); a++) { + CompareResult result = checkRecursive("[" + e + "]", actualJsonNode.get(a), expectedJsonNode.get(e)); + if (result.isApplicable() && !result.hasErrors()) { + matchingElementsIndexes.computeIfAbsent(e, key -> new HashSet<>()).add(a); + } + } + } + return matchingElementsIndexes; + } + + private CompareResult checkObject(JsonNode actualJsonNode, JsonNode expectedJsonNode) { + if (!expectedJsonNode.isObject()) { + return CompareResult.NOT_APPLICABLE; + } + List errors = new LinkedList<>(); Iterator> expectedFields = expectedJsonNode.fields(); while (expectedFields.hasNext()) { Map.Entry expectedField = expectedFields.next(); @@ -89,17 +160,18 @@ class JsonAssertContains { if (actualNode == null) { errors.add(String.format("Expected field '%s' to be present", path(expectedKey))); } else { - checkRecursive(expectedKey, actualNode, expectedField.getValue()); + CompareResult result = checkRecursive(expectedKey, actualNode, expectedField.getValue()); + errors.addAll(result.getErrors()); } } - // do not continue (scalar type check) - return false; + return CompareResult.errors(errors); } - private void checkValue(JsonNode actualJsonNode, JsonNode expectedJsonNode) { + private CompareResult checkValue(JsonNode actualJsonNode, JsonNode expectedJsonNode) { if (!expectedJsonNode.equals(actualJsonNode)) { - errors.add(String.format("Expected field '%s' to be equal to '%s' but was '%s'", path(), expectedJsonNode, actualJsonNode)); + return CompareResult.error(String.format("Expected field '%s' to be equal to '%s' but was '%s'", path(), expectedJsonNode, actualJsonNode)); } + return CompareResult.NO_ERRORS; } String path(String expectedKey) { diff --git a/ebean-test/src/test/java/io/ebean/test/JsonAssertContainsTest.java b/ebean-test/src/test/java/io/ebean/test/JsonAssertContainsTest.java index 2c25776f9..59d69c00b 100644 --- a/ebean-test/src/test/java/io/ebean/test/JsonAssertContainsTest.java +++ b/ebean-test/src/test/java/io/ebean/test/JsonAssertContainsTest.java @@ -1,10 +1,12 @@ package io.ebean.test; import com.fasterxml.jackson.databind.JsonNode; +import org.assertj.core.api.Assertions; import org.junit.Test; import java.util.stream.Stream; +import static io.ebean.test.Json.readNode; import static io.ebean.test.Json.readNodeFromResource; import static org.assertj.core.api.Assertions.assertThat; @@ -33,18 +35,21 @@ public class JsonAssertContainsTest { JsonAssertContains.assertContains(original, expected); } catch (AssertionError e) { String exceptionMessage = e.getMessage(); + System.out.println(exceptionMessage); Stream.of("Expected field 'someString1' to be equal to '\"aaaa\"' but was '\"string1\"", "Expected field 'someValue1' to be equal to '99' but was '1'", - "Expected field 'someArray1[0]' to be of type 'STRING' but was 'NUMBER", - "Expected field 'someArray2[0].value1' to be of type 'ARRAY' but was 'NUMBER'", - "Expected field 'someArray2[0].value2' to be of type 'OBJECT' but was 'STRING'", - "Expected field 'someArray2[0].array1[0]' to be '\"1\"' but was null", - "Expected field 'someArray2[0].object1.val5' to be present", - "Expected field 'someArray2[0].object1.val6' to be present", - "Expected field 'someArray2[0].object2' to be of type 'NULL' but was 'OBJECT'", - "Expected field 'someArray2[0].objectNull' to be of type 'OBJECT' but was 'NULL'") + "Unable to match expected element 'someArray1[0]' in the actual array", + "Expected field 'someObject1.value1' to be of type 'ARRAY' but was 'NUMBER'", + "Expected field 'someObject1.value2' to be of type 'OBJECT' but was 'STRING'", + "Unable to match expected element 'someObject1.array1[0]' in the actual array", + "Expected field 'someObject1.object1.val5' to be present", + "Expected field 'someObject1.object1.val6' to be present", + "Expected field 'someObject1.object2' to be of type 'NULL' but was 'OBJECT'", + "Expected field 'someObject1.objectNull' to be of type 'OBJECT' but was 'NULL'") .forEach(assertionError -> assertThat(exceptionMessage).contains(assertionError)); + return; } + Assertions.fail("Expected an exception to be thrown"); } @@ -59,7 +64,9 @@ public class JsonAssertContainsTest { Stream.of("Expected field 'someNull' to be of type 'NULL' but was 'STRING'", "Expected field 'extra' to be present") .forEach(assertionError -> assertThat(exceptionMessage).contains(assertionError)); + return; } + Assertions.fail("Expected an exception to be thrown"); } @Test @@ -72,7 +79,9 @@ public class JsonAssertContainsTest { String exceptionMessage = e.getMessage(); Stream.of("Expected field 'some' to be of type 'NUMBER' but was 'STRING'") .forEach(assertionError -> assertThat(exceptionMessage).contains(assertionError)); + return; } + Assertions.fail("Expected an exception to be thrown"); } @Test @@ -83,4 +92,39 @@ public class JsonAssertContainsTest { assertThat(contains.path("a")).isEqualTo("a"); assertThat(contains.path("b")).isEqualTo("b"); } + + + @Test + public void assertContainsNumbersArrayShuffled() { + JsonNode array = readNode("[2, 54, 13, 10]"); + JsonNode arrayShuffled = readNode("[2, 13, 10, 54]"); + + JsonAssertContains.assertContains(arrayShuffled, array); + } + + @Test + public void assertContainsObjectsArrayShuffled() { + JsonNode array = readNodeFromResource("/contains/array-objects.json"); + JsonNode arrayShuffled = readNodeFromResource("/contains/array-objects-shuffled.json"); + + JsonAssertContains.assertContains(arrayShuffled, array); + } + + @Test + public void assertArrayElementsNotFound() { + JsonNode original = readNodeFromResource("/contains/array-multi-match.json"); + JsonNode actual = readNodeFromResource("/contains/array-multi-match-duplicate-props.json"); + + try { + JsonAssertContains.assertContains(actual, original); + } catch (AssertionError e) { + String exceptionMessage = e.getMessage(); + Stream.of("Unable to match expected element '[5]' in the actual array", + "Unable to match expected element '[4]' in the actual array") + .forEach(assertionError -> assertThat(exceptionMessage).contains(assertionError)); + return; + } + + Assertions.fail("Expected an exception to be thrown"); + } } diff --git a/ebean-test/src/test/resources/contains/array-multi-match-duplicate-props.json b/ebean-test/src/test/resources/contains/array-multi-match-duplicate-props.json new file mode 100644 index 000000000..4dbcb1c10 --- /dev/null +++ b/ebean-test/src/test/resources/contains/array-multi-match-duplicate-props.json @@ -0,0 +1,18 @@ +[ + { + "b": 2, + "c": 3 + }, + { + "d": 4, + "e": 5 + }, + { + "a": 1, + "b": 2, + "c": 3 + }, + { + "b": 2 + } +] diff --git a/ebean-test/src/test/resources/contains/array-multi-match.json b/ebean-test/src/test/resources/contains/array-multi-match.json new file mode 100644 index 000000000..0d106dd88 --- /dev/null +++ b/ebean-test/src/test/resources/contains/array-multi-match.json @@ -0,0 +1,20 @@ +[ + { + "a": 1 + }, + { + "b": 2 + }, + { + "c": 3 + }, + { + "d": 4 + }, + { + "e": 5 + }, + { + "f": 6 + } +] diff --git a/ebean-test/src/test/resources/contains/array-objects-shuffled.json b/ebean-test/src/test/resources/contains/array-objects-shuffled.json new file mode 100644 index 000000000..8c28a8e7a --- /dev/null +++ b/ebean-test/src/test/resources/contains/array-objects-shuffled.json @@ -0,0 +1,14 @@ +[ + { + "id": "tyu", + "c": 3 + }, + { + "id": "zxy", + "a": 1 + }, + { + "id": "123", + "b": 2 + } +] \ No newline at end of file diff --git a/ebean-test/src/test/resources/contains/array-objects.json b/ebean-test/src/test/resources/contains/array-objects.json new file mode 100644 index 000000000..bfc5e4159 --- /dev/null +++ b/ebean-test/src/test/resources/contains/array-objects.json @@ -0,0 +1,11 @@ +[ + { + "a": 1 + }, + { + "b": 2 + }, + { + "c": 3 + } +]