sdkThreadPrefixes = new ArrayList<>();
+
+ private static final long REGISTRATION_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(2);
+
+ static {
+ sdkThreadPrefixes.add("pool"); // used for threads that execute the listeners code
+ sdkThreadPrefixes.add("ADB"); // module internal threads
+ }
+
+ /**
+ * {@code TestRule} which sets up the MobileCore for testing before each test execution, and
+ * tearsdown the MobileCore after test execution.
+ *
+ * To use, add the following to your test class:
+ *
+ * @Rule
+ * public TestHelper.SetupCoreRule coreRule = new TestHelper.SetupCoreRule();
+ *
+ */
+ public static class SetupCoreRule implements TestRule {
+
+ private static final String LOG_SOURCE = "SetupCoreRule";
+
+ @Override
+ public Statement apply(@NonNull final Statement base, @NonNull final Description description) {
+ return new Statement() {
+ @Override
+ public void evaluate() throws Throwable {
+ if (defaultApplication == null) {
+ Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
+ defaultApplication = Instrumentation.newApplication(CustomApplication.class, context);
+ }
+
+ MobileCoreHelper.resetSDK();
+ MobileCore.setLogLevel(LoggingMode.VERBOSE);
+ MobileCore.setApplication(defaultApplication);
+ MockDataStoreService.clearStores();
+ clearAllDatastores();
+ Log.debug(LOG_TAG, LOG_SOURCE, "Execute '%s'", description.getMethodName());
+
+ try {
+ base.evaluate();
+ } catch (Throwable e) {
+ Log.debug(LOG_TAG, LOG_SOURCE, "Wait after test failure.");
+ throw e; // rethrow test failure
+ } finally {
+ // After test execution
+ Log.debug(LOG_TAG, LOG_SOURCE, "Finished '%s'", description.getMethodName());
+ waitForThreads(5000); // wait to allow thread to run after test execution
+ MobileCoreHelper.resetSDK();
+ MockDataStoreService.clearStores();
+ clearAllDatastores();
+ resetTestExpectations();
+ resetServiceProvider();
+ TestPersistenceHelper.resetKnownPersistence();
+ }
+ }
+ };
+ }
+ }
+
+ /**
+ * Resets both the {@link MobileCore} and {@link ServiceProvider} instances without clearing persistence or database.
+ *
+ * After reset, this method initializes {@code MobileCore} and {@code ServiceProvider} for testing by
+ * setting the instrumented test application to {@code MobileCore}.
+ *
+ * Warning: If a custom network service is registered with the {@link ServiceProvider} to be
+ * used in the test case, it must be set again after calling this method.
+ *
+ * Warning: Call setupCoreRule() before calling this method.
+ *
+ * Note: This method does not clear shared preferences, the application cache directory,
+ * or the database directory.
+ */
+ public static void resetCoreHelper() {
+ MobileCoreHelper.resetSDK();
+ ServiceProviderHelper.resetServices();
+ MobileCore.setLogLevel(LoggingMode.VERBOSE);
+ MobileCore.setApplication(defaultApplication);
+ }
+
+ public static class LogOnErrorRule implements TestRule {
+
+ @Override
+ public Statement apply(@NonNull final Statement base, @NonNull final Description description) {
+ return new Statement() {
+ @Override
+ public void evaluate() throws Throwable {
+ try {
+ base.evaluate();
+ } catch (Throwable t) {
+ throw new Throwable(collectLogCat(description.getMethodName()), t);
+ }
+ }
+ };
+ }
+ }
+
+ /**
+ * Get the LogCat logs
+ */
+ private static String collectLogCat(final String methodName) {
+ Process process;
+ StringBuilder log = new StringBuilder();
+
+ try {
+ // Setting to just last 50 lines as logs are passed as Throwable stack trace which
+ // has a line limit. The SDK logs have many multi-line entries which blow up the logs quickly
+ // If the log string is too long, it can crash the Throwable call.
+ process = Runtime.getRuntime().exec("logcat -t 50 -d AdobeExperienceSDK:V TestRunner:I Hermetic:V *:S");
+ BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(process.getInputStream()));
+ String line = "";
+ boolean ignoreLines = false; // "started" line may not be in last 50 lines
+
+ while ((line = bufferedReader.readLine()) != null) {
+ if (ignoreLines && line.matches(".*started: " + methodName + ".*")) {
+ ignoreLines = false;
+ }
+
+ if (!ignoreLines) {
+ log.append(line).append("\n");
+ }
+ }
+ } catch (IOException e) {
+ // ignore
+ }
+
+ return log.toString();
+ }
+
+ /**
+ * Applies the provided configuration, registers the provided extensions synchronously,
+ * starts Core, and waits for the SDK thread processing to complete before returning.
+ *
+ * @param extensions the extensions that need to be registered
+ * @param configuration the initial configuration update that needs to be applied
+ */
+ public static void registerExtensions(
+ final List> extensions,
+ @Nullable final Map configuration
+ ) {
+ if (configuration != null) {
+ MobileCore.updateConfiguration(configuration);
+ }
+
+ final ADBCountDownLatch latch = new ADBCountDownLatch(1);
+ MobileCore.registerExtensions(extensions, o -> latch.countDown());
+
+ try {
+ latch.await(REGISTRATION_TIMEOUT_MS, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ fail("Failed to register extensions");
+ }
+ TestHelper.waitForThreads(2000);
+ }
+
+ /**
+ * Waits for all the known SDK threads to finish or fails the test after timeoutMillis if some of them are still running
+ * when the timer expires. If timeoutMillis is 0, a default timeout will be set = 1000ms
+ *
+ * @param timeoutMillis max waiting time
+ */
+ public static void waitForThreads(final int timeoutMillis) {
+ int TEST_DEFAULT_TIMEOUT_MS = 1000;
+ int TEST_DEFAULT_SLEEP_MS = 50;
+ int TEST_INITIAL_SLEEP_MS = 100;
+
+ long startTime = System.currentTimeMillis();
+ int timeoutTestMillis = timeoutMillis > 0 ? timeoutMillis : TEST_DEFAULT_TIMEOUT_MS;
+ int sleepTime = Math.min(timeoutTestMillis, TEST_DEFAULT_SLEEP_MS);
+
+ sleep(TEST_INITIAL_SLEEP_MS);
+ Set threadSet = getEligibleThreads();
+
+ while (threadSet.size() > 0 && ((System.currentTimeMillis() - startTime) < timeoutTestMillis)) {
+ Log.debug(LOG_TAG, LOG_SOURCE, "waitForThreads - Still waiting for " + threadSet.size() + " thread(s)");
+
+ for (Thread t : threadSet) {
+ Log.debug(
+ LOG_TAG,
+ LOG_SOURCE,
+ "waitForThreads - Waiting for thread " + t.getName() + " (" + t.getId() + ")"
+ );
+ boolean done = false;
+ boolean timedOut = false;
+
+ while (!done && !timedOut) {
+ if (
+ t.getState().equals(Thread.State.TERMINATED) ||
+ t.getState().equals(Thread.State.TIMED_WAITING) ||
+ t.getState().equals(Thread.State.WAITING)
+ ) {
+ //Cannot use the join() API since we use a cached thread pool, which
+ //means that we keep idle threads around for 60secs (default timeout).
+ done = true;
+ } else {
+ //blocking
+ sleep(sleepTime);
+ timedOut = (System.currentTimeMillis() - startTime) > timeoutTestMillis;
+ }
+ }
+
+ if (timedOut) {
+ Log.debug(
+ LOG_TAG,
+ LOG_SOURCE,
+ "waitForThreads - Timeout out waiting for thread " + t.getName() + " (" + t.getId() + ")"
+ );
+ } else {
+ Log.debug(
+ LOG_TAG,
+ LOG_SOURCE,
+ "waitForThreads - Done waiting for thread " + t.getName() + " (" + t.getId() + ")"
+ );
+ }
+ }
+
+ threadSet = getEligibleThreads();
+ }
+
+ Log.debug(LOG_TAG, LOG_SOURCE, "waitForThreads - All known SDK threads are terminated.");
+ }
+
+ /**
+ * Retrieves all the known SDK threads that are still running
+ * @return set of running tests
+ */
+ private static Set getEligibleThreads() {
+ Set threadSet = Thread.getAllStackTraces().keySet();
+ Set eligibleThreads = new HashSet<>();
+
+ for (Thread t : threadSet) {
+ if (
+ isAppThread(t) &&
+ !t.getState().equals(Thread.State.WAITING) &&
+ !t.getState().equals(Thread.State.TERMINATED) &&
+ !t.getState().equals(Thread.State.TIMED_WAITING)
+ ) {
+ eligibleThreads.add(t);
+ }
+ }
+
+ return eligibleThreads;
+ }
+
+ /**
+ * Checks if current thread is not a daemon and its name starts with one of the known SDK thread names specified here
+ * {@link #sdkThreadPrefixes}
+ *
+ * @param t current thread to verify
+ * @return true if it is a known thread, false otherwise
+ */
+ private static boolean isAppThread(final Thread t) {
+ if (t.isDaemon()) {
+ return false;
+ }
+
+ for (String prefix : sdkThreadPrefixes) {
+ if (t.getName().startsWith(prefix)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Resets the network and event test expectations.
+ */
+ public static void resetTestExpectations() {
+ Log.debug(LOG_TAG, LOG_SOURCE, "Resetting functional test expectations for events and network requests");
+ MonitorExtension.reset();
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Event Test Helpers
+ // ---------------------------------------------------------------------------------------------
+
+ /**
+ * Sets an expectation for a specific event type and source and how many times the event should be dispatched.
+ * @param type the event type
+ * @param source the event source
+ * @param count the expected number of times the event is dispatched
+ * @throws IllegalArgumentException if {@code count} is less than 1
+ */
+ public static void setExpectationEvent(final String type, final String source, final int count) {
+ if (count < 1) {
+ throw new IllegalArgumentException("Cannot set expectation event count less than 1!");
+ }
+
+ MonitorExtension.setExpectedEvent(type, source, count);
+ }
+
+ /**
+ * Asserts if all the expected events were received and fails if an unexpected event was seen.
+ * @param ignoreUnexpectedEvents if set on false, an assertion is made on unexpected events, otherwise the unexpected events are ignored
+ * @throws InterruptedException
+ * @see #setExpectationEvent(String, String, int)
+ * @see #assertUnexpectedEvents()
+ */
+ public static void assertExpectedEvents(final boolean ignoreUnexpectedEvents) throws InterruptedException {
+ Map expectedEvents = MonitorExtension.getExpectedEvents();
+
+ if (expectedEvents.isEmpty()) {
+ fail("There are no event expectations set, use this API after calling setExpectationEvent");
+ return;
+ }
+
+ for (Map.Entry expected : expectedEvents.entrySet()) {
+ boolean awaitResult = expected
+ .getValue()
+ .await(TestConstants.Defaults.WAIT_EVENT_TIMEOUT_MS, TimeUnit.MILLISECONDS);
+ assertTrue(
+ "Timed out waiting for event type " +
+ expected.getKey().type +
+ " and source " +
+ expected.getKey().source,
+ awaitResult
+ );
+ int expectedCount = expected.getValue().getInitialCount();
+ int receivedCount = expected.getValue().getCurrentCount();
+ String failMessage = String.format(
+ "Expected %d events for '%s', but received %d",
+ expectedCount,
+ expected.getKey(),
+ receivedCount
+ );
+ assertEquals(failMessage, expectedCount, receivedCount);
+ }
+
+ if (!ignoreUnexpectedEvents) {
+ assertUnexpectedEvents(false);
+ }
+ }
+
+ /**
+ * Asserts if any unexpected event was received. Use this method to verify the received events
+ * are correct when setting event expectations. Waits a short time before evaluating received
+ * events to allow all events to come in.
+ * @see #setExpectationEvent
+ */
+ public static void assertUnexpectedEvents() throws InterruptedException {
+ assertUnexpectedEvents(true);
+ }
+
+ /**
+ * Asserts if any unexpected event was received. Use this method to verify the received events
+ * are correct when setting event expectations.
+ * @see #setExpectationEvent
+ *
+ * @param shouldWait waits a short time to allow events to be received when true
+ */
+ public static void assertUnexpectedEvents(final boolean shouldWait) throws InterruptedException {
+ // Short wait to allow events to come in
+ if (shouldWait) {
+ sleep(TestConstants.Defaults.WAIT_TIMEOUT_MS);
+ }
+
+ int unexpectedEventsReceivedCount = 0;
+ StringBuilder unexpectedEventsErrorString = new StringBuilder();
+
+ Map> receivedEvents = MonitorExtension.getReceivedEvents();
+ Map expectedEvents = MonitorExtension.getExpectedEvents();
+
+ for (Map.Entry> receivedEvent : receivedEvents.entrySet()) {
+ ADBCountDownLatch expectedEventLatch = expectedEvents.get(receivedEvent.getKey());
+
+ if (expectedEventLatch != null) {
+ expectedEventLatch.await(TestConstants.Defaults.WAIT_EVENT_TIMEOUT_MS, TimeUnit.MILLISECONDS);
+ int expectedCount = expectedEventLatch.getInitialCount();
+ int receivedCount = receivedEvent.getValue().size();
+ String failMessage = String.format(
+ "Expected %d events for '%s', but received %d",
+ expectedCount,
+ receivedEvent.getKey(),
+ receivedCount
+ );
+ assertEquals(failMessage, expectedCount, receivedCount);
+ } else {
+ unexpectedEventsReceivedCount += receivedEvent.getValue().size();
+ unexpectedEventsErrorString.append(
+ String.format(
+ "(%s,%s,%d)",
+ receivedEvent.getKey().type,
+ receivedEvent.getKey().source,
+ receivedEvent.getValue().size()
+ )
+ );
+ Log.debug(
+ LOG_TAG,
+ LOG_SOURCE,
+ "Received unexpected event with type: %s source: %s",
+ receivedEvent.getKey().type,
+ receivedEvent.getKey().source
+ );
+ }
+ }
+
+ assertEquals(
+ String.format(
+ "Received %d unexpected event(s): %s",
+ unexpectedEventsReceivedCount,
+ unexpectedEventsErrorString
+ ),
+ 0,
+ unexpectedEventsReceivedCount
+ );
+ }
+
+ /**
+ * Returns the {@code Event}(s) dispatched through the Event Hub, or empty if none was found.
+ * Use this API after calling {@link #setExpectationEvent(String, String, int)} to wait for
+ * the expected events. The wait time for each event is {@link TestConstants.Defaults#WAIT_EVENT_TIMEOUT_MS}ms.
+ * @param type the event type as in the expectation
+ * @param source the event source as in the expectation
+ * @return list of events with the provided {@code type} and {@code source}, or empty if none was dispatched
+ * @throws InterruptedException
+ * @throws IllegalArgumentException if {@code type} or {@code source} are null or empty strings
+ */
+ public static List getDispatchedEventsWith(final String type, final String source)
+ throws InterruptedException {
+ return getDispatchedEventsWith(type, source, TestConstants.Defaults.WAIT_EVENT_TIMEOUT_MS);
+ }
+
+ /**
+ * Returns the {@code Event}(s) dispatched through the Event Hub, or empty if none was found.
+ * Use this API after calling {@link #setExpectationEvent(String, String, int)} to wait for the right amount of time
+ * @param type the event type as in the expectation
+ * @param source the event source as in the expectation
+ * @param timeout how long should this method wait for the expected event, in milliseconds.
+ * @return list of events with the provided {@code type} and {@code source}, or empty if none was dispatched
+ * @throws InterruptedException
+ * @throws IllegalArgumentException if {@code type} or {@code source} are null or empty strings
+ */
+ public static List getDispatchedEventsWith(final String type, final String source, int timeout)
+ throws InterruptedException {
+ EventSpec eventSpec = new EventSpec(source, type);
+
+ Map> receivedEvents = MonitorExtension.getReceivedEvents();
+ Map expectedEvents = MonitorExtension.getExpectedEvents();
+
+ ADBCountDownLatch expectedEventLatch = expectedEvents.get(eventSpec);
+
+ if (expectedEventLatch != null) {
+ boolean awaitResult = expectedEventLatch.await(timeout, TimeUnit.MILLISECONDS);
+ assertTrue(
+ "Timed out waiting for event type " + eventSpec.type + " and source " + eventSpec.source,
+ awaitResult
+ );
+ } else {
+ sleep(TestConstants.Defaults.WAIT_TIMEOUT_MS);
+ }
+
+ return receivedEvents.containsKey(eventSpec) ? receivedEvents.get(eventSpec) : Collections.emptyList();
+ }
+
+ /**
+ * Synchronous call to get the shared state for the specified {@code stateOwner}.
+ * This API throws an assertion failure in case of timeout.
+ *
+ * @param stateOwner the owner extension of the shared state (typically the name of the extension)
+ * @param timeout how long should this method wait for the requested shared state, in milliseconds
+ * @return latest shared state of the given {@code stateOwner} or null if no shared state was found
+ * @throws InterruptedException
+ */
+ public static Map getSharedStateFor(final String stateOwner, int timeout)
+ throws InterruptedException {
+ Event event = new Event.Builder(
+ "Get Shared State Request",
+ TestConstants.EventType.MONITOR,
+ TestConstants.EventSource.SHARED_STATE_REQUEST
+ )
+ .setEventData(
+ new HashMap() {
+ {
+ put(TestConstants.EventDataKey.STATE_OWNER, stateOwner);
+ }
+ }
+ )
+ .build();
+
+ final CountDownLatch latch = new CountDownLatch(1);
+ final Map sharedState = new HashMap<>();
+ MobileCore.dispatchEventWithResponseCallback(
+ event,
+ TestConstants.Defaults.WAIT_SHARED_STATE_TIMEOUT_MS,
+ new AdobeCallbackWithError() {
+ @Override
+ public void call(final Event event) {
+ if (event.getEventData() != null) {
+ sharedState.putAll(event.getEventData());
+ }
+
+ latch.countDown();
+ }
+
+ @Override
+ public void fail(final AdobeError adobeError) {
+ Log.debug(
+ LOG_TAG,
+ LOG_SOURCE,
+ "Failed to get shared state for %s: %s",
+ stateOwner,
+ adobeError.getErrorName()
+ );
+ }
+ }
+ );
+
+ assertTrue("Timeout waiting for shared state " + stateOwner, latch.await(timeout, TimeUnit.MILLISECONDS));
+ return sharedState.isEmpty() ? null : sharedState;
+ }
+
+ /**
+ * Synchronous call to get the XDM shared state for the specified {@code stateOwner}.
+ * This API throws an assertion failure in case of timeout.
+ *
+ * @param stateOwner the owner extension of the shared state (typically the name of the extension)
+ * @param timeout how long should this method wait for the requested shared state, in milliseconds
+ * @return latest shared state of the given {@code stateOwner} or null if no shared state was found
+ * @throws InterruptedException
+ */
+ public static Map getXDMSharedStateFor(final String stateOwner, int timeout)
+ throws InterruptedException {
+ Event event = new Event.Builder(
+ "Get Shared State Request",
+ TestConstants.EventType.MONITOR,
+ TestConstants.EventSource.XDM_SHARED_STATE_REQUEST
+ )
+ .setEventData(
+ new HashMap() {
+ {
+ put(TestConstants.EventDataKey.STATE_OWNER, stateOwner);
+ }
+ }
+ )
+ .build();
+
+ final ADBCountDownLatch latch = new ADBCountDownLatch(1);
+ final Map sharedState = new HashMap<>();
+ MobileCore.dispatchEventWithResponseCallback(
+ event,
+ TestConstants.Defaults.WAIT_SHARED_STATE_TIMEOUT_MS,
+ new AdobeCallbackWithError() {
+ @Override
+ public void fail(AdobeError adobeError) {
+ Log.debug(LOG_TAG, LOG_SOURCE, "Failed to get shared state for " + stateOwner + ": " + adobeError);
+ }
+
+ @Override
+ public void call(Event event) {
+ if (event.getEventData() != null) {
+ sharedState.putAll(event.getEventData());
+ }
+
+ latch.countDown();
+ }
+ }
+ );
+
+ assertTrue("Timeout waiting for shared state " + stateOwner, latch.await(timeout, TimeUnit.MILLISECONDS));
+ return sharedState.isEmpty() ? null : sharedState;
+ }
+
+ /**
+ * Pause test execution for the given {@code milliseconds}
+ * @param milliseconds the time to sleep the current thread.
+ */
+ public static void sleep(int milliseconds) {
+ try {
+ Thread.sleep(milliseconds);
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
+ /**
+ * Dummy Application for the test instrumentation
+ */
+ public static class CustomApplication extends Application {
+
+ public CustomApplication() {}
+ }
+
+ /**
+ * Call setupCoreRule() before calling this method.
+ */
+ private static void clearAllDatastores() {
+ final List knownDatastores = new ArrayList() {
+ {
+ add(TestConstants.SharedState.IDENTITY);
+ add(TestConstants.SharedState.CONSENT);
+ add(TestConstants.EDGE_DATA_STORAGE);
+ add("AdobeMobile_ConfigState");
+ }
+ };
+ final Application application = defaultApplication;
+
+ if (application == null) {
+ fail("TestHelper - Unable to clear datastores. Application is null, fast failing the test case.");
+ }
+
+ final Context context = application.getApplicationContext();
+
+ if (context == null) {
+ fail("TestHelper - Unable to clear datastores. Context is null, fast failing the test case.");
+ }
+
+ for (String datastore : knownDatastores) {
+ SharedPreferences sharedPreferences = context.getSharedPreferences(datastore, Context.MODE_PRIVATE);
+
+ if (sharedPreferences == null) {
+ fail("TestHelper - Unable to clear datastores. sharedPreferences is null, fast failing the test case.");
+ }
+
+ SharedPreferences.Editor editor = sharedPreferences.edit();
+ editor.clear();
+ editor.apply();
+ }
+ }
+
+ /**
+ * Reset the {@link ServiceProvider} by clearing all files under the application cache folder,
+ * clearing all files under the database folder, and instantiate new instances of each service provider
+ */
+ private static void resetServiceProvider() {
+ ServiceProviderHelper.cleanCacheDir();
+ ServiceProviderHelper.cleanDatabaseDir();
+ ServiceProviderHelper.resetServices();
+ }
+
+ /**
+ * Get bundled file from Assets folder as {@code InputStream}.
+ * Asset folder location "src/androidTest/assets/".
+ * Call setupCoreRule() before calling this method.
+ * @param filename file name of the asset
+ * @return an {@code InputStream} to the file asset, or null if the file could not be opened or
+ * no Application is set.
+ * @throws IOException
+ */
+ public static InputStream getAsset(final String filename) throws IOException {
+ if (defaultApplication == null) {
+ return null;
+ }
+
+ AssetManager assetManager = defaultApplication.getApplicationContext().getAssets();
+ return assetManager.open(filename);
+ }
+}
diff --git a/code/testutils/src/main/java/com/adobe/marketing/mobile/util/TestPersistenceHelper.java b/code/testutils/src/main/java/com/adobe/marketing/mobile/util/TestPersistenceHelper.java
new file mode 100644
index 000000000..2c7fa3839
--- /dev/null
+++ b/code/testutils/src/main/java/com/adobe/marketing/mobile/util/TestPersistenceHelper.java
@@ -0,0 +1,70 @@
+/*
+ Copyright 2024 Adobe. All rights reserved.
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License. You may obtain a copy
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
+ Unless required by applicable law or agreed to in writing, software distributed under
+ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+ OF ANY KIND, either express or implied. See the License for the specific language
+ governing permissions and limitations under the License.
+*/
+
+package com.adobe.marketing.mobile.util;
+
+import com.adobe.marketing.mobile.services.NamedCollection;
+import com.adobe.marketing.mobile.services.ServiceProvider;
+import java.util.ArrayList;
+
+/**
+ * Helper class to update and remove persisted data to extension concerned with testing Identity.
+ */
+public class TestPersistenceHelper {
+
+ private static ArrayList knownDatastoreName = new ArrayList() {
+ {
+ add(TestConstants.DataStoreKey.IDENTITY_DATASTORE);
+ add(TestConstants.DataStoreKey.CONFIG_DATASTORE);
+ add(TestConstants.DataStoreKey.IDENTITY_DIRECT_DATASTORE);
+ }
+ };
+
+ /**
+ * Helper method to update the {@link NamedCollection} data.
+ *
+ * @param datastore the name of the datastore to be updated
+ * @param key the persisted data key that has to be updated
+ * @param value the new value
+ */
+ public static void updatePersistence(final String datastore, final String key, final String value) {
+ NamedCollection dataStore = ServiceProvider.getInstance().getDataStoreService().getNamedCollection(datastore);
+
+ dataStore.setString(key, value);
+ }
+
+ /**
+ * Reads the requested persisted data from datastore.
+ *
+ * @param datastore the name of the datastore to be read
+ * @param key the key that needs to be read
+ * @return {@link String} value of persisted data. {@code null} if data is not found in {@link NamedCollection}
+ */
+ public static String readPersistedData(final String datastore, final String key) {
+ NamedCollection dataStore = ServiceProvider.getInstance().getDataStoreService().getNamedCollection(datastore);
+
+ return dataStore.getString(key, null);
+ }
+
+ /**
+ * Clears the Configuration and Consent extension's persisted data
+ */
+ public static void resetKnownPersistence() {
+ for (String eachDatastore : knownDatastoreName) {
+ NamedCollection dataStore = ServiceProvider
+ .getInstance()
+ .getDataStoreService()
+ .getNamedCollection(eachDatastore);
+
+ dataStore.removeAll();
+ }
+ }
+}
diff --git a/code/testutils/src/main/java/com/adobe/marketing/mobile/util/TestUtils.java b/code/testutils/src/main/java/com/adobe/marketing/mobile/util/TestUtils.java
new file mode 100644
index 000000000..2844ae1d5
--- /dev/null
+++ b/code/testutils/src/main/java/com/adobe/marketing/mobile/util/TestUtils.java
@@ -0,0 +1,117 @@
+/*
+ Copyright 2021 Adobe. All rights reserved.
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License. You may obtain a copy
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
+ Unless required by applicable law or agreed to in writing, software distributed under
+ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+ OF ANY KIND, either express or implied. See the License for the specific language
+ governing permissions and limitations under the License.
+*/
+
+package com.adobe.marketing.mobile.util;
+
+import static com.adobe.marketing.mobile.util.TestConstants.LOG_TAG;
+
+import com.adobe.marketing.mobile.services.Log;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fasterxml.jackson.databind.node.ValueNode;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+import org.json.JSONObject;
+
+public class TestUtils {
+
+ private static final String LOG_SOURCE = "TestUtils";
+
+ private TestUtils() {}
+
+ /**
+ * Serialize the given {@code map} to a JSON Object, then flattens to {@code Map}.
+ * For example, a JSON such as "{xdm: {stitchId: myID, eventType: myType}}" is flattened
+ * to two map elements "xdm.stitchId" = "myID" and "xdm.eventType" = "myType".
+ * @param map map with JSON structure to flatten
+ * @return new map with flattened structure
+ */
+ public static Map flattenMap(final Map map) {
+ if (map == null || map.isEmpty()) {
+ return Collections.emptyMap();
+ }
+
+ try {
+ JSONObject jsonObject = new JSONObject(map);
+ Map payloadMap = new HashMap<>();
+ addKeys("", new ObjectMapper().readTree(jsonObject.toString()), payloadMap);
+ return payloadMap;
+ } catch (IOException e) {
+ Log.error(LOG_TAG, LOG_SOURCE, "Failed to parse JSON object to tree structure.");
+ }
+
+ return Collections.emptyMap();
+ }
+
+ /**
+ * Deserialize the given {@code bytes} to a flattened {@code Map}.
+ * For example, a JSON such as "{xdm: {stitchId: myID, eventType: myType}}" is flattened
+ * to two map elements "xdm.stitchId" = "myID" and "xdm.eventType" = "myType".
+ * The given {@code bytes} must be a serialized JSON Object.
+ * @param bytes serialized JSON Object string
+ * @return new map with flattned structure
+ */
+ public static Map flattenBytes(final byte[] bytes) {
+ if (bytes == null || bytes.length == 0) {
+ return Collections.emptyMap();
+ }
+
+ try {
+ Map payloadMap = new HashMap<>();
+ TestUtils.addKeys("", new ObjectMapper().readTree(bytes), payloadMap);
+ return payloadMap;
+ } catch (IOException e) {
+ Log.error(LOG_TAG, LOG_SOURCE, "Failed to parse JSON payload to tree structure.");
+ return Collections.emptyMap();
+ }
+ }
+
+ /**
+ * Deserialize {@code JsonNode} and flatten to provided {@code map}.
+ * For example, a JSON such as "{xdm: {stitchId: myID, eventType: myType}}" is flattened
+ * to two map elements "xdm.stitchId" = "myID" and "xdm.eventType" = "myType".
+ *
+ * Method is called recursively. To use, call with an empty path such as
+ * {@code addKeys("", new ObjectMapper().readTree(JsonNodeAsString), map);}
+ *
+ * @param currentPath the path in {@code JsonNode} to process
+ * @param jsonNode {@link JsonNode} to deserialize
+ * @param map {@code Map} instance to store flattened JSON result
+ *
+ * @see Stack Overflow post
+ */
+ private static void addKeys(String currentPath, JsonNode jsonNode, Map map) {
+ if (jsonNode.isObject()) {
+ ObjectNode objectNode = (ObjectNode) jsonNode;
+ Iterator> iter = objectNode.fields();
+ String pathPrefix = currentPath.isEmpty() ? "" : currentPath + ".";
+
+ while (iter.hasNext()) {
+ Map.Entry entry = iter.next();
+ addKeys(pathPrefix + entry.getKey(), entry.getValue(), map);
+ }
+ } else if (jsonNode.isArray()) {
+ ArrayNode arrayNode = (ArrayNode) jsonNode;
+
+ for (int i = 0; i < arrayNode.size(); i++) {
+ addKeys(currentPath + "[" + i + "]", arrayNode.get(i), map);
+ }
+ } else if (jsonNode.isValueNode()) {
+ ValueNode valueNode = (ValueNode) jsonNode;
+ map.put(currentPath, valueNode.asText());
+ }
+ }
+}
diff --git a/code/testutils/src/test/java/com/adobe/marketing/mobile/util/JSONAssertsHelpersTests.kt b/code/testutils/src/test/java/com/adobe/marketing/mobile/util/JSONAssertsHelpersTests.kt
new file mode 100644
index 000000000..e34cdb9dd
--- /dev/null
+++ b/code/testutils/src/test/java/com/adobe/marketing/mobile/util/JSONAssertsHelpersTests.kt
@@ -0,0 +1,258 @@
+/*
+ Copyright 2024 Adobe. All rights reserved.
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License. You may obtain a copy
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
+ Unless required by applicable law or agreed to in writing, software distributed under
+ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+ OF ANY KIND, either express or implied. See the License for the specific language
+ governing permissions and limitations under the License.
+*/
+
+package com.adobe.marketing.mobile.util
+
+import org.json.JSONArray
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class JSONAssertsHelpersTests {
+ @Test
+ fun testJSONRepresentation_whenBothValuesAreNull_shouldPass() {
+ val result = JSONAsserts.getJSONRepresentation(null)
+ assertEquals(JSONObject.NULL, result)
+ }
+
+ @Test
+ fun testJSONRepresentation_whenValueIsJSONObject_shouldPass() {
+ val jsonObject = JSONObject(mapOf("key" to "value"))
+ val result = JSONAsserts.getJSONRepresentation(jsonObject)
+ assertEquals(jsonObject, result)
+ }
+
+ @Test
+ fun testJSONRepresentation_whenValueIsJSONArray_shouldPass() {
+ val jsonArray = JSONArray(listOf("value1", "value2"))
+ val result = JSONAsserts.getJSONRepresentation(jsonArray)
+ assertEquals(jsonArray, result)
+ }
+
+ @Test
+ fun testJSONRepresentation_whenValueIsStringRepresentingJSONObject_shouldPass() {
+ val jsonString = """{"key":"value"}"""
+ val result = JSONAsserts.getJSONRepresentation(jsonString)
+ assertTrue(result is JSONObject)
+ assertEquals("value", (result as JSONObject).getString("key"))
+ }
+
+ @Test
+ fun testJSONRepresentation_whenValueIsStringRepresentingJSONArray_shouldPass() {
+ val jsonString = """["value1", "value2"]"""
+ val result = JSONAsserts.getJSONRepresentation(jsonString)
+ assertTrue(result is JSONArray)
+ assertEquals("value1", (result as JSONArray).getString(0))
+ assertEquals("value2", (result as JSONArray).getString(1))
+ }
+
+ @Test
+ fun testJSONRepresentation_whenKeyIsInvalidJSONString_shouldPass() {
+ val jsonString = """{key:"value"}""" // Invalid JSON key string
+ val result = JSONAsserts.getJSONRepresentation(jsonString)
+ assertTrue(result is JSONObject)
+ val jsonObject = result as JSONObject
+ assertEquals("value", jsonObject.getString("key"))
+ }
+
+ @Test
+ fun testJSONRepresentation_whenValueIsMapWithNullValues_shouldReplaceNullWithJSONObjectNull() {
+ val map = mapOf("key1" to "value1", "key2" to null)
+ val result = JSONAsserts.getJSONRepresentation(map)
+ assertTrue(result is JSONObject)
+ val jsonObject = result as JSONObject
+ assertEquals("value1", jsonObject.getString("key1"))
+ assertEquals(JSONObject.NULL, jsonObject.get("key2"))
+ }
+
+ @Test
+ fun testJSONRepresentation_whenValueIsListWithNullValues_shouldReplaceNullWithJSONObjectNull() {
+ val list = listOf("value1", null, "value2")
+ val result = JSONAsserts.getJSONRepresentation(list)
+ assertTrue(result is JSONArray)
+ val jsonArray = result as JSONArray
+ assertEquals("value1", jsonArray.getString(0))
+ assertEquals(JSONObject.NULL, jsonArray.get(1))
+ assertEquals("value2", jsonArray.getString(2))
+ }
+
+ @Test
+ fun testJSONRepresentation_whenValueIsArrayWithNullValues_shouldReplaceNullWithJSONObjectNull() {
+ val array = arrayOf("value1", null, "value2")
+ val result = JSONAsserts.getJSONRepresentation(array)
+ assertTrue(result is JSONArray)
+ val jsonArray = result as JSONArray
+ assertEquals("value1", jsonArray.getString(0))
+ assertEquals(JSONObject.NULL, jsonArray.get(1))
+ assertEquals("value2", jsonArray.getString(2))
+ }
+
+ // region - Nested collection tests
+ @Test
+ fun testNestedMapToListWithNullValue() {
+ val input = mapOf(
+ "key1" to listOf("value1", null, "value2")
+ )
+ val expected = JSONObject(
+ mapOf(
+ "key1" to JSONArray(listOf("value1", JSONObject.NULL, "value2"))
+ )
+ )
+ val result = JSONAsserts.getJSONRepresentation(input)
+ assertEquals(expected.toString(), result.toString())
+ }
+
+ @Test
+ fun testNestedMapToArrayWithNullValue() {
+ val input = mapOf(
+ "key1" to arrayOf("value1", null, "value2")
+ )
+ val expected = JSONObject(
+ mapOf(
+ "key1" to JSONArray(listOf("value1", JSONObject.NULL, "value2"))
+ )
+ )
+ val result = JSONAsserts.getJSONRepresentation(input)
+ assertEquals(expected.toString(), result.toString())
+ }
+
+ @Test
+ fun testNestedArrayToMapWithNullValue() {
+ val input = arrayOf(
+ mapOf("key1" to "value1", "key2" to null)
+ )
+ val expected = JSONArray(
+ listOf(
+ JSONObject(
+ mapOf("key1" to "value1", "key2" to JSONObject.NULL)
+ )
+ )
+ )
+ val result = JSONAsserts.getJSONRepresentation(input)
+ assertEquals(expected.toString(), result.toString())
+ }
+
+ @Test
+ fun testNestedListToMapWithNullValue() {
+ val input = listOf(
+ mapOf("key1" to "value1", "key2" to null)
+ )
+ val expected = JSONArray(
+ listOf(
+ JSONObject(
+ mapOf("key1" to "value1", "key2" to JSONObject.NULL)
+ )
+ )
+ )
+ val result = JSONAsserts.getJSONRepresentation(input)
+ assertEquals(expected.toString(), result.toString())
+ }
+
+ @Test
+ fun testNestedArrayToListWithNullValue() {
+ val input = arrayOf(
+ listOf("value1", null, "value2")
+ )
+ val expected = JSONArray(
+ listOf(
+ JSONArray(listOf("value1", JSONObject.NULL, "value2"))
+ )
+ )
+ val result = JSONAsserts.getJSONRepresentation(input)
+ assertEquals(expected.toString(), result.toString())
+ }
+
+ @Test
+ fun testNestedListToArrayWithNullValue() {
+ val input = listOf(
+ arrayOf("value1", null, "value2")
+ )
+ val expected = JSONArray(
+ listOf(
+ JSONArray(listOf("value1", JSONObject.NULL, "value2"))
+ )
+ )
+ val result = JSONAsserts.getJSONRepresentation(input)
+ assertEquals(expected.toString(), result.toString())
+ }
+
+ @Test
+ fun testNestedMapWithinMapWithNullValue() {
+ val input = mapOf(
+ "key1" to mapOf("nestedKey1" to "value1", "nestedKey2" to null)
+ )
+ val expected = JSONObject(
+ mapOf(
+ "key1" to JSONObject(
+ mapOf("nestedKey1" to "value1", "nestedKey2" to JSONObject.NULL)
+ )
+ )
+ )
+ val result = JSONAsserts.getJSONRepresentation(input)
+ assertEquals(expected.toString(), result.toString())
+ }
+
+ @Test
+ fun testNestedListWithinListWithNullValue() {
+ val input = listOf(
+ listOf("value1", null, "value2")
+ )
+ val expected = JSONArray(
+ listOf(
+ JSONArray(listOf("value1", JSONObject.NULL, "value2"))
+ )
+ )
+ val result = JSONAsserts.getJSONRepresentation(input)
+ assertEquals(expected.toString(), result.toString())
+ }
+
+ @Test
+ fun testNestedArrayWithinArrayWithNullValue() {
+ val input = arrayOf(
+ arrayOf("value1", null, "value2")
+ )
+ val expected = JSONArray(
+ listOf(
+ JSONArray(listOf("value1", JSONObject.NULL, "value2"))
+ )
+ )
+ val result = JSONAsserts.getJSONRepresentation(input)
+ assertEquals(expected.toString(), result.toString())
+ }
+ // endregion - Nested collection tests
+
+ // region - Invalid input tests
+ @Test(expected = IllegalArgumentException::class)
+ fun testJSONRepresentation_whenValueIsNotJSONString_shouldThrowException() {
+ val value = "simple string"
+ JSONAsserts.getJSONRepresentation(value)
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun testJSONRepresentation_whenValueIsNumber_shouldThrowException() {
+ val value = 123.456
+ JSONAsserts.getJSONRepresentation(value)
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun testJSONRepresentation_whenValueIsBoolean_shouldThrowException() {
+ val value = true
+ JSONAsserts.getJSONRepresentation(value)
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun testJSONRepresentation_whenValueIsUnsupportedType_shouldThrowException() {
+ val value = Any() // Unsupported type
+ JSONAsserts.getJSONRepresentation(value)
+ }
+ // endregion - Invalid input tests
+}
diff --git a/code/testutils/src/test/java/com/adobe/marketing/mobile/util/JSONAssertsParameterizedTests.kt b/code/testutils/src/test/java/com/adobe/marketing/mobile/util/JSONAssertsParameterizedTests.kt
new file mode 100644
index 000000000..78ebe2a19
--- /dev/null
+++ b/code/testutils/src/test/java/com/adobe/marketing/mobile/util/JSONAssertsParameterizedTests.kt
@@ -0,0 +1,531 @@
+/*
+ Copyright 2023 Adobe. All rights reserved.
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License. You may obtain a copy
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
+ Unless required by applicable law or agreed to in writing, software distributed under
+ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+ OF ANY KIND, either express or implied. See the License for the specific language
+ governing permissions and limitations under the License.
+*/
+
+package com.adobe.marketing.mobile.util
+
+import com.adobe.marketing.mobile.util.JSONAsserts.assertEquals
+import com.adobe.marketing.mobile.util.JSONAsserts.assertExactMatch
+import com.adobe.marketing.mobile.util.JSONAsserts.assertTypeMatch
+import org.json.JSONArray
+import org.json.JSONObject
+import org.junit.Assert
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+import org.junit.runners.Suite
+import kotlin.test.assertFailsWith
+
+interface TestParams {
+ val expected: Any
+ val actual: Any
+}
+
+data class PlainParams(override val expected: Any, override val actual: Any) : TestParams
+
+data class JSONArrayWrappedParams(private val expectedVal: Any, private val actualVal: Any?) : TestParams {
+ override val expected: JSONArray = JSONArray().put(expectedVal)
+ override val actual: JSONArray = JSONArray().put(actualVal)
+}
+
+data class JSONObjectWrappedParams(val keyName: String, private val expectedVal: Any, private val actualVal: Any?) : TestParams {
+ override val expected: JSONObject = JSONObject().put(keyName, expectedVal)
+ override val actual: JSONObject = JSONObject().put(keyName, actualVal)
+}
+
+@RunWith(Suite::class)
+@Suite.SuiteClasses(
+ JSONAssertsParameterizedTests.ValueValidationTests::class,
+ JSONAssertsParameterizedTests.TypeValidationTests::class,
+ JSONAssertsParameterizedTests.ExtensibleCollectionValidationTests::class,
+ JSONAssertsParameterizedTests.TypeValidationFailureTests::class,
+ JSONAssertsParameterizedTests.SpecialKeyTest::class,
+ JSONAssertsParameterizedTests.AlternatePathValueDictionaryTest::class,
+ JSONAssertsParameterizedTests.AlternatePathValueArrayTest::class,
+ JSONAssertsParameterizedTests.AlternatePathTypeDictionaryTest::class,
+ JSONAssertsParameterizedTests.AlternatePathTypeArrayTest::class,
+ JSONAssertsParameterizedTests.SpecialKeyAlternatePathTest::class,
+ JSONAssertsParameterizedTests.ExpectedArrayLargerTest::class,
+ JSONAssertsParameterizedTests.ExpectedDictionaryLargerTest::class
+)
+class JSONAssertsParameterizedTests {
+ companion object {
+ fun createPlainParams(vararg pairs: Pair): List {
+ return pairs.map { (expected, actual) -> PlainParams(expected, actual) }
+ }
+
+ fun createJSONArrayWrappedParams(vararg pairs: Pair): List {
+ return pairs.map { (expected, actual) -> JSONArrayWrappedParams(expected, actual) }
+ }
+
+ fun createJSONObjectWrappedParams(keyName: String, vararg pairs: Pair): List {
+ return pairs.map { (expected, actual) -> JSONObjectWrappedParams(keyName, expected, actual) }
+ }
+ }
+
+ /**
+ * Validates that [JSONArray]s and [JSONObject]s compare correctly, including nested structures.
+ */
+ @RunWith(Parameterized::class)
+ class ValueValidationTests(private val params: TestParams) {
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(name = "{index}: test with expected={0}, actual={1}")
+ fun data(): Collection {
+ return createPlainParams(
+ JSONArray() to JSONArray(), // Empty array
+ JSONArray("[[[]]]") to JSONArray("[[[]]]"), // Nested arrays
+ JSONObject() to JSONObject(), // Empty dictionary
+ JSONObject("""{ "key1": 1 }""") to JSONObject("""{ "key1": 1 }"""), // Simple key value pair
+ JSONObject("""{ "key1": { "key2": {} } }""") to JSONObject("""{ "key1": { "key2": {} } }""") // Nested objects
+ )
+ }
+ }
+
+ @Test
+ fun `should match basic values`() {
+ assertEquals(params.expected, params.actual)
+ assertExactMatch(params.expected, params.actual)
+ assertTypeMatch(params.expected, params.actual)
+ }
+ }
+
+ /**
+ * Validates that various value types inside a [JSONArray] compare correctly, including nested structures.
+ */
+ @RunWith(Parameterized::class)
+ class ValueValidationArrayTests(private val params: TestParams) {
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(name = "{index}: test with expected={0}, actual={1}")
+ fun data(): Collection {
+ return createJSONArrayWrappedParams(
+ 5 to 5,
+ 5.0 to 5.0,
+ true to true,
+ "a" to "a",
+ "안녕하세요" to "안녕하세요",
+ JSONObject.NULL to JSONObject.NULL,
+ JSONArray() to JSONArray(),
+ JSONArray("[[[]]]") to JSONArray("[[[]]]"),
+ JSONObject() to JSONObject(),
+ JSONObject("""{ "key1": 1 }""") to JSONObject("""{ "key1": 1 }"""),
+ JSONObject("""{ "key1": { "key2": {} } }""") to JSONObject("""{ "key1": { "key2": {} } }""")
+ )
+ }
+ }
+
+ @Test
+ fun `should match basic values`() {
+ assertEquals(params.expected, params.actual)
+ assertExactMatch(params.expected, params.actual)
+ assertTypeMatch(params.expected, params.actual)
+ }
+ }
+
+ /**
+ * Validates that various value types inside a [JSONObject] compare correctly, including nested structures.
+ */
+ @RunWith(Parameterized::class)
+ class ValueValidationDictionaryTests(private val params: TestParams) {
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(name = "{index}: test with expected={0}, actual={1}")
+ fun data(): Collection {
+ return createJSONObjectWrappedParams(
+ "key",
+ 5 to 5,
+ 5.0 to 5.0,
+ true to true,
+ "a" to "a",
+ "안녕하세요" to "안녕하세요",
+ JSONObject.NULL to JSONObject.NULL,
+ JSONArray() to JSONArray(),
+ JSONArray("[[[]]]") to JSONArray("[[[]]]"),
+ JSONObject() to JSONObject(),
+ JSONObject("""{ "key1": 1 }""") to JSONObject("""{ "key1": 1 }"""),
+ JSONObject("""{ "key1": { "key2": {} } }""") to JSONObject("""{ "key1": { "key2": {} } }""")
+ )
+ }
+ }
+
+ @Test
+ fun `should match basic values`() {
+ assertEquals(params.expected, params.actual)
+ assertExactMatch(params.expected, params.actual)
+ assertTypeMatch(params.expected, params.actual)
+ }
+ }
+
+ @RunWith(Parameterized::class)
+ class TypeValidationTests(private val params: TestParams) {
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(name = "{index}: test with expected={0}, actual={1}")
+ fun data(): Collection {
+ return createJSONArrayWrappedParams(
+ 5 to 10, // Int
+ 5.0 to 10.0, // Double
+ true to false, // Boolean
+ "a" to "b", // String
+ "안" to "안녕하세요", // Non-Latin String
+ JSONObject("""{ "key1": 1 }""") to JSONObject("""{ "key1": 2 }"""), // Key value pair
+ JSONObject("""{ "key1": { "key2": "a" } }""") to JSONObject("""{ "key1": { "key2": "b", "key3": 3 } }""") // Nested partial by type
+ )
+ }
+ }
+
+ @Test
+ fun `should match only by type for values of the same type`() {
+ Assert.assertThrows(AssertionError::class.java) {
+ assertEquals(params.expected, params.actual)
+ }
+ Assert.assertThrows(AssertionError::class.java) {
+ assertExactMatch(params.expected, params.actual)
+ }
+ assertTypeMatch(params.expected, params.actual)
+ }
+ }
+
+ @RunWith(Parameterized::class)
+ class ExtensibleCollectionValidationTests(private val params: TestParams) {
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(name = "{index}: test with expected={0}, actual={1}")
+ fun data(): Collection {
+ return createPlainParams(
+ JSONArray() to JSONArray(listOf(1)),
+ JSONArray(listOf(1, 2, 3)) to JSONArray(listOf(1, 2, 3, 4)),
+ JSONObject() to JSONObject(mapOf("k" to "v")),
+ JSONObject(mapOf("key1" to 1, "key2" to "a", "key3" to 1.0, "key4" to true)) to
+ JSONObject(mapOf("key1" to 1, "key2" to "a", "key3" to 1.0, "key4" to true, "key5" to "extra"))
+ )
+ }
+ }
+
+ @Test
+ fun `should pass flexible matching when expected is a subset`() {
+ assertFailsWith("Validation should fail when collection sizes are different.") {
+ assertEquals(params.expected, params.actual)
+ }
+ assertExactMatch(params.expected, params.actual)
+ assertTypeMatch(params.expected, params.actual)
+ }
+ }
+
+ @RunWith(Parameterized::class)
+ class TypeValidationFailureTests(private val params: TestParams) {
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(name = "{index}: test with expected={0}, actual={1}")
+ fun data(): Collection {
+ return createJSONArrayWrappedParams(
+ 1 to 2.0,
+ 1 to "a",
+ 1 to true,
+ 1 to JSONObject(),
+ 1 to JSONArray(),
+ 1 to JSONObject.NULL,
+ 1 to null,
+ 2.0 to "a",
+ 2.0 to true,
+ 2.0 to JSONObject(),
+ 2.0 to JSONArray(),
+ 2.0 to JSONObject.NULL,
+ 2.0 to null,
+ "a" to true,
+ "a" to JSONObject(),
+ "a" to JSONArray(),
+ "a" to JSONObject.NULL,
+ "a" to null,
+ true to JSONObject(),
+ true to JSONArray(),
+ true to JSONObject.NULL,
+ true to null,
+ JSONObject() to JSONArray(),
+ JSONObject() to JSONObject.NULL,
+ JSONObject() to null,
+ JSONArray() to JSONObject.NULL,
+ JSONArray() to null,
+ JSONObject("""{ "key1": 1 }""") to JSONObject("""{ "key2": 1 }""")
+ )
+ }
+ }
+
+ @Test
+ fun `should detect type mismatch or nullability issues`() {
+ assertFailsWith("Validation should fail when value types mismatch.") {
+ assertEquals(params.expected, params.actual)
+ }
+ assertFailsWith("Validation should fail when value types mismatch.") {
+ assertTypeMatch(params.expected, params.actual)
+ }
+ assertFailsWith("Validation should fail when value types mismatch.") {
+ assertExactMatch(params.expected, params.actual)
+ }
+ }
+ }
+
+ @RunWith(Parameterized::class)
+ class SpecialKeyTest(private val params: TestParams) {
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(name = "{index}: test with expected={0}, actual={1}")
+ fun data(): Collection {
+ return createPlainParams(
+ JSONObject("""{ "": 1 }""") to JSONObject("""{ "": 1 }"""),
+ JSONObject("""{ "\\": 1 }""") to JSONObject("""{ "\\": 1 }"""),
+ JSONObject("""{ "\\\\": 1 }""") to JSONObject("""{ "\\\\": 1 }"""),
+ JSONObject("""{ ".": 1 }""") to JSONObject("""{ ".": 1 }"""),
+ JSONObject("""{ "k.1.2.3": 1 }""") to JSONObject("""{ "k.1.2.3": 1 }"""),
+ JSONObject("""{ "k.": 1 }""") to JSONObject("""{ "k.": 1 }"""),
+ JSONObject("""{ "\"": 1 }""") to JSONObject("""{ "\"": 1 }"""),
+ JSONObject("""{ "'": 1 }""") to JSONObject("""{ "'": 1 }"""),
+ JSONObject("""{ "\'": 1 }""") to JSONObject("""{ "\'": 1 }"""),
+ JSONObject("""{ "key with space": 1 }""") to JSONObject("""{ "key with space": 1 }"""),
+ JSONObject("""{ "\n": 1 }""") to JSONObject("""{ "\n": 1 }"""),
+ JSONObject("""{ "key \t \n newline": 1 }""") to JSONObject("""{ "key \t \n newline": 1 }"""),
+ JSONObject("""{ "안녕하세요": 1 }""") to JSONObject("""{ "안녕하세요": 1 }""")
+ )
+ }
+ }
+
+ @Test
+ fun `should handle special characters in JSON keys correctly`() {
+ assertEquals(params.expected, params.actual)
+ assertExactMatch(params.expected, params.actual)
+ assertTypeMatch(params.expected, params.actual)
+ }
+ }
+
+ @RunWith(Parameterized::class)
+ class AlternatePathValueDictionaryTest(private val params: TestParams) {
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(name = "{index}: test with key={0}, expected={1}, actual={2}")
+ fun data(): Collection {
+ return createPlainParams(
+ JSONObject("""{ "key1": 1 }""") to JSONObject("""{ "key1": 1 }"""),
+ JSONObject("""{ "key1": 2.0 }""") to JSONObject("""{ "key1": 2.0 }"""),
+ JSONObject("""{ "key1": "a" }""") to JSONObject("""{ "key1": "a" }"""),
+ JSONObject("""{ "key1": true }""") to JSONObject("""{ "key1": true }"""),
+ JSONObject("""{ "key1": {} }""") to JSONObject("""{ "key1": {} }"""),
+ JSONObject("""{ "key1": [] }""") to JSONObject("""{ "key1": [] }"""),
+ JSONObject("""{ "key1": null }""") to JSONObject("""{ "key1": null }""")
+ )
+ }
+ }
+
+ @Test
+ fun `should not fail because of alternate path`() {
+ assertExactMatch(params.expected, params.actual, ValueTypeMatch("key1"))
+ assertTypeMatch(params.expected, params.actual, ValueExactMatch("key1"))
+ }
+ }
+
+ @RunWith(Parameterized::class)
+ class AlternatePathValueArrayTest(private val params: TestParams) {
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(name = "{index}: test with key={0}, expected={1}, actual={2}")
+ fun data(): Collection {
+ return createPlainParams(
+ JSONArray("[1]") to JSONArray("[1]"),
+ JSONArray("[2.0]") to JSONArray("[2.0]"),
+ JSONArray("[\"a\"]") to JSONArray("[\"a\"]"),
+ JSONArray("[true]") to JSONArray("[true]"),
+ JSONArray("[{}]") to JSONArray("[{}]"),
+ JSONArray("[[]]") to JSONArray("[[]]"),
+ JSONArray("[null]") to JSONArray("[null]")
+ )
+ }
+ }
+
+ @Test
+ fun `should not fail because of alternate path`() {
+ assertExactMatch(params.expected, params.actual, ValueTypeMatch("[0]"))
+ assertTypeMatch(params.expected, params.actual, ValueExactMatch("[0]"))
+
+ assertExactMatch(params.expected, params.actual, ValueTypeMatch("[*]"))
+ assertTypeMatch(params.expected, params.actual, ValueExactMatch("[*]"))
+ }
+ }
+
+ @RunWith(Parameterized::class)
+ class AlternatePathTypeDictionaryTest(private val params: TestParams) {
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(name = "{index}: test with key={0}, expected={1}, actual={2}")
+ fun data(): Collection {
+ return createJSONObjectWrappedParams(
+ "key",
+ 1 to 2,
+ "a" to "b",
+ 1.0 to 2.0,
+ true to false
+ )
+ }
+ }
+
+ @Test
+ fun `should apply alternate path to matching logic`() {
+ assertExactMatch(params.expected, params.actual, ValueTypeMatch("key"))
+ assertFailsWith("Validation should fail when path option is not satisfied.") {
+ assertTypeMatch(params.expected, params.actual, ValueExactMatch("key"))
+ }
+ }
+ }
+
+ @RunWith(Parameterized::class)
+ class AlternatePathTypeArrayTest(private val params: TestParams) {
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(name = "{index}: test with key={0}, expected={1}, actual={2}")
+ fun data(): Collection {
+ return createJSONArrayWrappedParams(
+ 1 to 2,
+ "a" to "b",
+ 1.0 to 2.0,
+ true to false
+ )
+ }
+ }
+
+ @Test
+ fun `should apply alternate path to matching logic`() {
+ assertExactMatch(params.expected, params.actual, ValueTypeMatch("[0]"))
+ assertFailsWith("Validation should fail when mismatched types are not equivalent under alternate paths.") {
+ assertTypeMatch(params.expected, params.actual, ValueExactMatch("[0]"))
+ }
+ }
+ }
+
+ @RunWith(Parameterized::class)
+ class SpecialKeyAlternatePathTest(private val keyPath: String, private val params: TestParams) {
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(name = "{index}: test with key={0}, expected={1}, actual={2}")
+ fun data(): Collection {
+ return listOf(
+ "key1." to (JSONObject("""{ "key1": { "": 1 } }""") to JSONObject("""{ "key1": { "": 2 } }""")),
+ "key1..key3" to (JSONObject("""{ "key1": { "": { "key3": 1 } } }""") to JSONObject("""{ "key1": { "": { "key3": 2 } } }""")),
+ ".key2." to (JSONObject("""{ "": { "key2": { "": 1 } } }""") to JSONObject("""{ "": { "key2": { "": 2 } } }""")),
+ "\\\\." to (JSONObject("""{ "\\.": 1 }""") to JSONObject("""{ "\\.": 2 }""")),
+ "" to (JSONObject("""{ "": 1 }""") to JSONObject("""{ "": 2 }""")),
+ "." to (JSONObject("""{ "": { "": 1 } }""") to JSONObject("""{ "": { "": 2 } }""")),
+ "..." to (JSONObject("""{ "": { "": { "": { "": 1 } } } }""") to JSONObject("""{ "": { "": { "": { "": 2 } } } }""")),
+ "\\" to (JSONObject("""{ "\\": 1 }""") to JSONObject("""{ "\\": 2 }""")),
+ "\\\\" to (JSONObject("""{ "\\\\": 1 }""") to JSONObject("""{ "\\\\": 2 }""")),
+ "\\." to (JSONObject("""{ ".": 1 }""") to JSONObject("""{ ".": 2 }""")),
+ "k\\.1\\.2\\.3" to (JSONObject("""{ "k.1.2.3": 1 }""") to JSONObject("""{ "k.1.2.3": 2 }""")),
+ "k\\." to (JSONObject("""{ "k.": 1 }""") to JSONObject("""{ "k.": 2 }""")),
+ "\"" to (JSONObject("""{ "\"": 1 }""") to JSONObject("""{ "\"": 2 }""")),
+ "\'" to (JSONObject("""{ "\'": 1 }""") to JSONObject("""{ "\'": 2 }""")),
+ "'" to (JSONObject("""{ "'": 1 }""") to JSONObject("""{ "'": 2 }""")),
+ "key with space" to (JSONObject("""{ "key with space": 1 }""") to JSONObject("""{ "key with space": 2 }""")),
+ "\n" to (JSONObject("""{ "\n": 1 }""") to JSONObject("""{ "\n": 2 }""")),
+ "key \t \n newline" to (JSONObject("""{ "key \t \n newline": 1 }""") to JSONObject("""{ "key \t \n newline": 2 }""")),
+ "안녕하세요" to (JSONObject("""{ "안녕하세요": 1 }""") to JSONObject("""{ "안녕하세요": 2 }""")),
+ "a]" to (JSONObject("""{ "a]": 1 }""") to JSONObject("""{ "a]": 2 }""")),
+ "a[" to (JSONObject("""{ "a[": 1 }""") to JSONObject("""{ "a[": 2 }""")),
+ "a[1]b" to (JSONObject("""{ "a[1]b": 1 }""") to JSONObject("""{ "a[1]b": 2 }""")),
+ "key1\\[0\\]" to (JSONObject("""{ "key1[0]": 1 }""") to JSONObject("""{ "key1[0]": 2 }""")),
+ "\\[1\\][0]" to (JSONObject("""{ "[1]": [1] }""") to JSONObject("""{ "[1]": [2] }""")),
+ "\\[1\\\\][0]" to (JSONObject("""{ "[1\\]": [1] }""") to JSONObject("""{ "[1\\]": [2] }"""))
+ ).map { (keyPath, pair) ->
+ arrayOf(keyPath, PlainParams(pair.first, pair.second))
+ }
+ }
+ }
+
+ @Test
+ fun `should handle special keys in alternate paths`() {
+ assertExactMatch(params.expected, params.actual, ValueTypeMatch(keyPath))
+ assertFailsWith("Validation should fail when special key paths result in type mismatches.") {
+ assertTypeMatch(params.expected, params.actual, ValueExactMatch(keyPath))
+ }
+ }
+ }
+
+ @RunWith(Parameterized::class)
+ class ExpectedArrayLargerTest(private val keyPaths: List, private val params: TestParams) {
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(name = "{index}: test with alternateMatchPaths={0}")
+ fun data(): Collection {
+ return listOf(
+ listOf() to (JSONArray("[1, 2]") to JSONArray("[1]")),
+ listOf("[0]") to (JSONArray("[1, 2]") to JSONArray("[1]")),
+ listOf("[1]") to (JSONArray("[1, 2]") to JSONArray("[1]")),
+ listOf("[0]", "[1]") to (JSONArray("[1, 2]") to JSONArray("[1]")),
+ listOf("[*]") to (JSONArray("[1, 2]") to JSONArray("[1]"))
+ ).map { (keyPaths, pair) ->
+ arrayOf(keyPaths, PlainParams(pair.first, pair.second))
+ }
+ }
+ }
+
+ /**
+ * Validates that a larger expected array compared to actual will throw errors
+ * even when using alternate match paths.
+ *
+ * Consequence: Guarantees that array size validation isn't affected by alternate paths.
+ */
+ @Test
+ fun `should error on larger expected arrays`() {
+ assertFailsWith("Validation should fail when expected array is larger regardless of alternate paths.") {
+ assertEquals(params.expected, params.actual)
+ }
+ assertFailsWith("Validation should fail when exact matching is enforced with larger expected arrays.") {
+ assertExactMatch(params.expected, params.actual, ValueTypeMatch(keyPaths))
+ }
+ assertFailsWith("Validation should fail on type matching with larger expected arrays.") {
+ assertTypeMatch(params.expected, params.actual, ValueExactMatch(keyPaths))
+ }
+ }
+ }
+
+ @RunWith(Parameterized::class)
+ class ExpectedDictionaryLargerTest(private val keyPaths: List, private val params: TestParams) {
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(name = "{index}: test with alternateMatchPaths={0}")
+ fun data(): Collection {
+ return listOf(
+ emptyList() to (JSONObject("""{ "key1": 1, "key2": 2 }""") to JSONObject("""{ "key1": 1}""")),
+ listOf("key1") to (JSONObject("""{ "key1": 1, "key2": 2 }""") to JSONObject("""{ "key1": 1}""")),
+ listOf("key2") to (JSONObject("""{ "key1": 1, "key2": 2 }""") to JSONObject("""{ "key1": 1}""")),
+ listOf("key1", "key2") to (JSONObject("""{ "key1": 1, "key2": 2 }""") to JSONObject("""{ "key1": 1}""")),
+ ).map { (keyPaths, pair) ->
+ arrayOf(keyPaths, PlainParams(pair.first, pair.second))
+ }
+ }
+ }
+
+ /**
+ * Validates that a larger expected dictionary compared to actual will throw errors
+ * even when using alternate match paths.
+ *
+ * Consequence: Guarantees that dictionary size validation isn't affected by alternate paths.
+ */
+ @Test
+ fun `should error on larger expected maps`() {
+ Assert.assertThrows(AssertionError::class.java) {
+ assertEquals(params.expected, params.actual)
+ }
+ Assert.assertThrows(AssertionError::class.java) {
+ assertExactMatch(params.expected, params.actual, ValueTypeMatch(keyPaths))
+ }
+ Assert.assertThrows(AssertionError::class.java) {
+ assertTypeMatch(params.expected, params.actual, ValueExactMatch(keyPaths))
+ }
+ }
+ }
+}
diff --git a/code/testutils/src/test/java/com/adobe/marketing/mobile/util/JSONAssertsPathOptionsTests.kt b/code/testutils/src/test/java/com/adobe/marketing/mobile/util/JSONAssertsPathOptionsTests.kt
new file mode 100644
index 000000000..acadb6551
--- /dev/null
+++ b/code/testutils/src/test/java/com/adobe/marketing/mobile/util/JSONAssertsPathOptionsTests.kt
@@ -0,0 +1,1228 @@
+/*
+ Copyright 2024 Adobe. All rights reserved.
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License. You may obtain a copy
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
+ Unless required by applicable law or agreed to in writing, software distributed under
+ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+ OF ANY KIND, either express or implied. See the License for the specific language
+ governing permissions and limitations under the License.
+*/
+
+package com.adobe.marketing.mobile.util
+
+import com.adobe.marketing.mobile.util.JSONAsserts.assertExactMatch
+import com.adobe.marketing.mobile.util.JSONAsserts.assertTypeMatch
+import com.adobe.marketing.mobile.util.NodeConfig.Scope.SingleNode
+import com.adobe.marketing.mobile.util.NodeConfig.Scope.Subtree
+import org.junit.Test
+import kotlin.test.assertFailsWith
+
+/**
+ * This test suite validates the common logic for all path options. It covers:
+ * 1. Node scope comparison: SingleNode versus Subtree.
+ * 2. Application of multiple options simultaneously.
+ * 3. Option overriding.
+ * 4. Specifying multiple paths for a single option.
+ */
+class JSONAssertsPathOptionsTests {
+ /**
+ * Validates that simple index array paths in comparison options work correctly.
+ */
+ @Test
+ fun testSatisfiedPathOption_PassesWithArray() {
+ val expected = "[1]"
+ val actual = "[2]"
+
+ assertExactMatch(expected, actual, ValueTypeMatch("[0]"))
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch(expected, actual, ValueExactMatch("[0]"))
+ }
+ }
+
+ /**
+ * Validates that simple dictionary key paths in comparison options work correctly.
+ */
+ @Test
+ fun testSatisfiedPathOption_PassesWithDictionary() {
+ val expected = """
+ {
+ "key0": 1
+ }
+ """
+
+ val actual = """
+ {
+ "key0": 2
+ }
+ """
+
+ assertExactMatch(expected, actual, ValueTypeMatch("key0"))
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch(expected, actual, ValueExactMatch("key0"))
+ }
+ }
+
+ /**
+ * Validates that key paths specifying nested dictionary keys in the JSON hierarchy work correctly.
+ */
+ @Test
+ fun testSatisfiedNestedPathOption_PassesWithDictionary() {
+ val expected = """
+ {
+ "key0-0": 1,
+ "key0-1": {
+ "key1-0": 1
+ }
+ }
+ """
+
+ val actual = """
+ {
+ "key0-0": 1,
+ "key0-1": {
+ "key1-0": 2
+ }
+ }
+ """
+
+ assertExactMatch(expected, actual, ValueTypeMatch("key0-1.key1-0"))
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch(expected, actual, ValueExactMatch("key0-1.key1-0"))
+ }
+ }
+
+ /**
+ * Validates that key paths specifying nested array keys in the JSON hierarchy work correctly.
+ */
+ @Test
+ fun testSatisfiedNestedPathOption_PassesWithArray() {
+ val expected = "[1, [1]]"
+ val actual = "[1, [2]]"
+
+ assertExactMatch(expected, actual, ValueTypeMatch("[1][0]"))
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch(expected, actual, ValueExactMatch("[1][0]"))
+ }
+ }
+
+ /**
+ * Validates that an unsatisfied path option with a specific path applied to arrays correctly
+ * triggers a test failure.
+ */
+ @Test
+ fun testUnsatisfiedPathOption_FailsWithArray() {
+ val expected = "[1, [1]]"
+ val actual = "[1, [1, 1]]"
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertExactMatch(expected, actual, CollectionEqualCount("[1]"))
+ }
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch(expected, actual, CollectionEqualCount("[1]"))
+ }
+ }
+
+ /**
+ * Validates that an unsatisfied path option with a default path applied to arrays correctly
+ * triggers a test failure.
+ */
+ @Test
+ fun testUnsatisfiedPathOption_UsingDefaultPathOption_FailsWithArray() {
+ val expected = "[1]"
+ val actual = "[1, 2]"
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertExactMatch(expected, actual, CollectionEqualCount())
+ }
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch(expected, actual, CollectionEqualCount())
+ }
+ }
+
+ /**
+ * Validates that an unsatisfied path option with a specific path applied to dictionaries correctly
+ * triggers a test failure.
+ */
+ @Test
+ fun testUnsatisfiedPathOption_FailsWithDictionary() {
+ val expected = """
+ {
+ "key0-0": 1,
+ "key0-1": {
+ "key1-0": 1
+ }
+ }
+ """
+ val actual = """
+ {
+ "key0-0": 1,
+ "key0-1": {
+ "key1-0": 1,
+ "key1-1": 1
+ }
+ }
+ """
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertExactMatch(expected, actual, CollectionEqualCount("key0-1"))
+ }
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch(expected, actual, CollectionEqualCount("key0-1"))
+ }
+ }
+
+ /**
+ * Validates that an unsatisfied path option with a default path applied to dictionaries correctly
+ * triggers a test failure.
+ */
+ @Test
+ fun testUnsatisfiedPathOption_UsingDefaultPathOption_FailsWithDictionary() {
+ val expected = """
+ {
+ "key0-0": 1
+ }
+ """
+ val actual = """
+ {
+ "key0-0": 1,
+ "key0-1": 1
+ }
+ """
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertExactMatch(expected, actual, CollectionEqualCount())
+ }
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch(expected, actual, CollectionEqualCount())
+ }
+ }
+
+ /**
+ * Validates that an unsatisfied path option with a nested array path applied to arrays correctly
+ * triggers a test failure.
+ */
+ @Test
+ fun testUnsatisfiedNestedPathOption_FailsWithArray() {
+ val expected = "[1, [[1]]]"
+ val actual = "[1, [[1, 2]]]"
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertExactMatch(expected, actual, CollectionEqualCount("[1][0]"))
+ }
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch(expected, actual, CollectionEqualCount("[1][0]"))
+ }
+ }
+
+ /**
+ * Validates that an unsatisfied path option with a nested dictionary key path applied to dictionaries
+ * correctly triggers a test failure.
+ */
+ @Test
+ fun testUnsatisfiedNestedPathOption_FailsWithDictionary() {
+ val expected = """
+ {
+ "key0-0": 1,
+ "key0-1": {
+ "key1-0": {
+ "key2-0": 1
+ }
+ }
+ }
+ """
+
+ val actual = """
+ {
+ "key0-0": 1,
+ "key0-1": {
+ "key1-0": {
+ "key2-0": 1,
+ "key2-1": 1
+ }
+ }
+ }
+ """
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertExactMatch(expected, actual, CollectionEqualCount("key0-1.key1-0"))
+ }
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch(expected, actual, CollectionEqualCount("key0-1.key1-0"))
+ }
+ }
+
+ /**
+ * Validates that path options with nonexistent paths applied to arrays do not affect validation results.
+ */
+ @Test
+ fun testNonexistentExpectedPathDoesNotAffectValidation_withArray() {
+ val expected = "[1]"
+ val actual = "[1, [1]]"
+
+ assertExactMatch(expected, actual, CollectionEqualCount("[1]"))
+ assertTypeMatch(expected, actual, CollectionEqualCount("[1]"))
+ }
+
+ /**
+ * Validates that path options with nonexistent paths applied to dictionaries do not affect validation results.
+ */
+ @Test
+ fun testNonexistentExpectedPathDoesNotAffectValidation_withDictionary() {
+ val expected = """
+ {
+ "key0-0": 1
+ }
+ """
+
+ val actual = """
+ {
+ "key0-0": 1,
+ "key0-1": 1
+ }
+ """
+
+ assertExactMatch(expected, actual, CollectionEqualCount("key-doesnt-exist"))
+ assertTypeMatch(expected, actual, CollectionEqualCount("key-doesnt-exist"))
+ }
+
+ /**
+ * Validates that path options with dictionary key paths applied to arrays do not affect validation results.
+ */
+ @Test
+ fun testInvalidExpectedPathDoesNotAffectValidation_withArray() {
+ val expected = "[1]"
+ val actual = "[1, [1]]"
+
+ assertExactMatch(expected, actual, CollectionEqualCount("key0"))
+ assertTypeMatch(expected, actual, CollectionEqualCount("key0"))
+ }
+
+ /**
+ * Validates that path options with array key paths applied to dictionaries do not affect validation results.
+ */
+ @Test
+ fun testInvalidExpectedPathDoesNotAffectValidation_withDictionary() {
+ val expected = """
+ {
+ "key0-0": 1
+ }
+ """
+
+ val actual = """
+ {
+ "key0-0": 1,
+ "key0-1": 1
+ }
+ """
+
+ assertExactMatch(expected, actual, CollectionEqualCount("[0]"))
+ assertTypeMatch(expected, actual, CollectionEqualCount("[0]"))
+ }
+
+ /**
+ * Validates that default path options for the same option type are overridden by the latest path
+ * option provided.
+ */
+ @Test
+ fun testOrderDependentOptionOverride() {
+ val expected = """
+ {
+ "key1": 1
+ }
+ """
+
+ val actual = """
+ {
+ "key1": 1,
+ "key2": 2
+ }
+ """
+
+ assertExactMatch(expected, actual, CollectionEqualCount(), CollectionEqualCount(false))
+ assertTypeMatch(expected, actual, CollectionEqualCount(), CollectionEqualCount(false))
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertExactMatch(expected, actual, CollectionEqualCount(false), CollectionEqualCount())
+ }
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch(expected, actual, CollectionEqualCount(false), CollectionEqualCount())
+ }
+ }
+
+ /**
+ * Validates that path options for the same path and option type are overridden by the latest path
+ * option provided.
+ */
+ @Test
+ fun testOrderDependentOptionOverride_WithSpecificKey() {
+ val expected = """
+ {
+ "key1": [1]
+ }
+ """
+
+ val actual = """
+ {
+ "key1": [1, 2]
+ }
+ """
+
+ assertExactMatch(expected, actual, CollectionEqualCount("key1"), CollectionEqualCount(false, "key1"))
+ assertTypeMatch(expected, actual, CollectionEqualCount("key1"), CollectionEqualCount(false, "key1"))
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertExactMatch(expected, actual, CollectionEqualCount(false, "key1"), CollectionEqualCount("key1"))
+ }
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch(expected, actual, CollectionEqualCount(false, "key1"), CollectionEqualCount("key1"))
+ }
+ }
+
+ /**
+ * Validates that variadic path options and array-style path options applied to arrays have the same results.
+ */
+ @Test
+ fun testVariadicAndArrayPathOptionsBehaveTheSame_withArray() {
+ val expected = "[1]"
+ val actual = "[2]"
+
+ assertExactMatch(expected, actual, listOf(ValueTypeMatch("[0]")))
+ assertExactMatch(expected, actual, ValueTypeMatch("[0]"))
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch(expected, actual, listOf(ValueExactMatch("[0]")))
+ }
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch(expected, actual, ValueExactMatch("[0]"))
+ }
+ }
+
+ /**
+ * Validates that variadic path options and array-style path options applied to dictionaries have the same results.
+ */
+ @Test
+ fun testVariadicAndArrayPathOptionsBehaveTheSame_withDictionary() {
+ val expected = """
+ {
+ "key0": 1
+ }
+ """
+
+ val actual = """
+ {
+ "key0": 2
+ }
+ """
+
+ assertExactMatch(expected, actual, listOf(ValueTypeMatch("key0")))
+ assertExactMatch(expected, actual, ValueTypeMatch("key0"))
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch(expected, actual, listOf(ValueExactMatch("key0")))
+ }
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch(expected, actual, ValueExactMatch("key0"))
+ }
+ }
+
+ /**
+ * Validates that path options with variadic paths and array-style paths applied to arrays have the same results.
+ */
+ @Test
+ fun testVariadicAndArrayPathsBehaveTheSame_withArray() {
+ val expected = "[1, 1]"
+ val actual = "[2, 2]"
+
+ assertExactMatch(expected, actual, ValueTypeMatch(listOf("[0]", "[1]")))
+ assertExactMatch(expected, actual, ValueTypeMatch("[0]", "[1]"))
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch(expected, actual, ValueExactMatch(listOf("[0]", "[1]")))
+ }
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch(expected, actual, ValueExactMatch("[0]", "[1]"))
+ }
+ }
+
+ /**
+ * Validates that path options with variadic paths and array-style paths applied to dictionaries have the same results.
+ */
+ @Test
+ fun testVariadicAndArrayPathsBehaveTheSame_withDictionary() {
+ val expected = """
+ {
+ "key0": 1,
+ "key1": 1
+ }
+ """
+
+ val actual = """
+ {
+ "key0": 2,
+ "key1": 2
+ }
+ """
+
+ assertExactMatch(expected, actual, ValueTypeMatch(listOf("key0", "key1")))
+ assertExactMatch(expected, actual, ValueTypeMatch("key0", "key1"))
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch(expected, actual, ValueExactMatch(listOf("key0", "key1")))
+ }
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch(expected, actual, ValueExactMatch("key0", "key1"))
+ }
+ }
+
+ /**
+ * Validates that a path option with subtree scope applied to arrays works correctly.
+ */
+ @Test
+ fun testSubtreeOptionPropagates_WithArray() {
+ val expected = "[1, [1]]"
+ val actual = "[1, [2]]"
+
+ assertExactMatch(expected, actual, ValueTypeMatch(Subtree))
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch(expected, actual, ValueExactMatch(Subtree))
+ }
+ }
+
+ /**
+ * Validates that a path option with subtree scope applied to dictionaries works correctly.
+ */
+ @Test
+ fun testSubtreeOptionPropagates_WithDictionary() {
+ val expected = """
+ {
+ "key0-0": {
+ "key1-0": 1
+ }
+ }
+ """
+
+ val actual = """
+ {
+ "key0-0": {
+ "key1-0": 2
+ }
+ }
+ """
+
+ assertExactMatch(expected, actual, ValueTypeMatch(Subtree))
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch(expected, actual, ValueExactMatch(Subtree))
+ }
+ }
+
+ /**
+ * Validates that a path option with single node scope applied to dictionaries works correctly.
+ */
+ @Test
+ fun testSingleNodeOption_DoesNotPropagate_WithDictionary() {
+ val expected = """
+ {
+ "key0-0": 1,
+ "key0-1": {
+ "key1-0": 1
+ }
+ }
+ """
+
+ val actual = """
+ {
+ "key0-0": 1,
+ "key0-1": {
+ "key1-0": 2
+ }
+ }
+ """
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertExactMatch(expected, actual, ValueTypeMatch(SingleNode))
+ }
+ assertTypeMatch(expected, actual, ValueExactMatch(SingleNode))
+ }
+
+ /**
+ * Validates that a path option with single node scope applied to arrays works correctly.
+ */
+ @Test
+ fun testSingleNodeOption_DoesNotPropagate_WithArray() {
+ val expected = "[1, [1]]"
+ val actual = "[1, [2]]"
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertExactMatch(expected, actual, ValueTypeMatch(SingleNode))
+ }
+ assertTypeMatch(expected, actual, ValueExactMatch(SingleNode))
+ }
+
+ /**
+ * Validates that a subtree scope path option can be overridden at the single node level in dictionaries.
+ */
+ @Test
+ fun testSubtreeOption_OverriddenBySingleNode() {
+ val expected = """
+ {
+ "key0-0": {
+ "key1-0": {
+ "key2-0": 1
+ }
+ }
+ }
+ """
+
+ val actual = """
+ {
+ "key0-0": {
+ "key1-0": {
+ "key2-0": 1
+ },
+ "key1-1": 1
+ }
+ }
+ """
+
+ assertExactMatch(expected, actual, CollectionEqualCount(Subtree), CollectionEqualCount(false, "key0-0"))
+
+ // Sanity check: Override without `SingleNode` should fail
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertExactMatch(expected, actual, CollectionEqualCount(Subtree))
+ }
+ }
+
+ /**
+ * Validates that a subtree scope path option can be overridden at the subtree level in dictionaries.
+ */
+ @Test
+ fun testSubtreeOption_OverriddenAtDifferentLevels() {
+ val expected = """
+ {
+ "key0-0": {
+ "key1-0": {
+ "key2-0": 1
+ }
+ }
+ }
+ """
+
+ val actual = """
+ {
+ "key0-0": {
+ "key1-0": {
+ "key2-0": 1,
+ "key2-1": 1
+ },
+ "key1-1": 1
+ }
+ }
+ """
+
+ assertExactMatch(expected, actual, CollectionEqualCount(Subtree), CollectionEqualCount(false, Subtree, "key0-0"))
+
+ // Sanity check: Override without `Subtree` should fail
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertExactMatch(expected, actual, CollectionEqualCount(Subtree), CollectionEqualCount(false, "key0-0"))
+ }
+ }
+
+ /**
+ * Validates that different path options with overlapping subtree scopes in dictionaries are order-independent
+ * and do not interfere with each other.
+ */
+ @Test
+ fun testSubtreeValues_NotIncorrectlyOverridden_WhenSettingMultiple() {
+ val expected = """
+ {
+ "key1": {
+ "key2": {
+ "key3": [
+ {
+ "key4": "STRING_TYPE"
+ }
+ ]
+ }
+ }
+ }
+ """
+
+ val actual = """
+ {
+ "key1": {
+ "key2": {
+ "key3": [
+ {
+ "key4": "abc"
+ }
+ ]
+ }
+ }
+ }
+ """
+
+ assertExactMatch(expected, actual, ValueTypeMatch(Subtree), CollectionEqualCount(Subtree))
+ assertExactMatch(expected, actual, CollectionEqualCount(Subtree), ValueTypeMatch(Subtree))
+ }
+
+ /**
+ * Validates that different path options with different but overlapping subtree scopes in dictionaries
+ * are order-independent and do not interfere with each other.
+ */
+ @Test
+ fun testSubtreeValues_whenDifferentLevels_NotIncorrectlyOverridden_WhenSettingMultiple() {
+ val expected = """
+ {
+ "key1": {
+ "key2": {
+ "key3": [
+ {
+ "key4": "STRING_TYPE"
+ }
+ ]
+ }
+ }
+ }
+ """
+
+ val actual = """
+ {
+ "key1": {
+ "key2": {
+ "key3": [
+ {
+ "key4": "abc"
+ }
+ ]
+ }
+ }
+ }
+ """
+
+ assertExactMatch(expected, actual, ValueTypeMatch(Subtree, "key1.key2.key3"), CollectionEqualCount(Subtree))
+ assertExactMatch(expected, actual, CollectionEqualCount(Subtree), ValueTypeMatch(Subtree, "key1.key2.key3"))
+ }
+
+ /**
+ * Validates that path options are order-independent.
+ */
+ @Test
+ fun testPathOptions_OrderIndependence() {
+ val expected = """
+ {
+ "key0": 1,
+ "key1": 1
+ }
+ """
+
+ val actual = """
+ {
+ "key0": 2,
+ "key1": 2
+ }
+ """
+
+ assertExactMatch(expected, actual, ValueTypeMatch("key0"), ValueTypeMatch("key1"))
+ assertExactMatch(expected, actual, ValueTypeMatch("key1"), ValueTypeMatch("key0"))
+ }
+
+ /**
+ * Validates that different path options can apply to the same path.
+ */
+ @Test
+ fun testPathOptions_OverlappingConditions() {
+ val expected = """
+ {
+ "key1": [2]
+ }
+ """
+
+ val actual = """
+ {
+ "key1": ["a", "b", 1]
+ }
+ """
+
+ assertExactMatch(expected, actual, ValueTypeMatch("key1[0]"), AnyOrderMatch("key1[0]"))
+ }
+
+ /**
+ * Validates that a path option can specify multiple paths at once when applied to arrays.
+ */
+ @Test
+ fun testMultiPath_whenArray() {
+ val expected = "[1, 1]"
+ val actual = "[2, 2]"
+
+ assertExactMatch(expected, actual, ValueTypeMatch("[0]", "[1]"))
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch(expected, actual, ValueExactMatch("[0]", "[1]"))
+ }
+ }
+
+ /**
+ * Validates that a path option can specify multiple paths at once when applied to dictionaries.
+ */
+ @Test
+ fun testMultiPath_whenDictionary() {
+ val expected = """
+ {
+ "key0": 1,
+ "key1": 1
+ }
+ """
+
+ val actual = """
+ {
+ "key0": 2,
+ "key1": 2
+ }
+ """
+
+ assertExactMatch(expected, actual, ValueTypeMatch("key0", "key1"))
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch(expected, actual, ValueExactMatch("key0", "key1"))
+ }
+ }
+
+ /**
+ * Validates that a path option specifying multiple paths in an array properly propagates the subtree scope to each path.
+ */
+ @Test
+ fun testMultiPath_SubtreePropagates_whenArray() {
+ val expected = """
+ [
+ [
+ [1], [1]
+ ],
+ [
+ [1], [1]
+ ]
+ ]
+ """
+
+ val actual = """
+ [
+ [
+ [2], [2]
+ ],
+ [
+ [2], [2]
+ ]
+ ]
+ """
+
+ assertExactMatch(expected, actual, ValueTypeMatch(Subtree, "[0]", "[1]"))
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch(expected, actual, ValueExactMatch(Subtree, "[0]", "[1]"))
+ }
+ }
+
+ /**
+ * Validates that a path option specifying multiple paths in a dictionary properly propagates the subtree scope to each path.
+ */
+ @Test
+ fun testMultiPath_SubtreePropagates_whenDictionary() {
+ val expected = """
+ {
+ "key0-0": {
+ "key1-0": {
+ "key2-0": 1
+ },
+ "key1-1": {
+ "key2-0": 1
+ }
+ },
+ "key0-1": {
+ "key1-0": {
+ "key2-0": 1
+ },
+ "key1-1": {
+ "key2-0": 1
+ }
+ }
+ }
+ """
+
+ val actual = """
+ {
+ "key0-0": {
+ "key1-0": {
+ "key2-0": 2
+ },
+ "key1-1": {
+ "key2-0": 2
+ }
+ },
+ "key0-1": {
+ "key1-0": {
+ "key2-0": 2
+ },
+ "key1-1": {
+ "key2-0": 2
+ }
+ }
+ }
+ """
+
+ assertExactMatch(expected, actual, ValueTypeMatch(Subtree, "key0-0", "key0-1"))
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch(expected, actual, ValueExactMatch(Subtree, "key0-0", "key0-1"))
+ }
+ }
+
+ /**
+ * Validates that path options set to inactive (`false`) are correctly applied in validation logic.
+ */
+ @Test
+ fun testSetting_isActiveToFalse() {
+ val expected = "[1]"
+ val actual = "[1, [1]]"
+
+ assertExactMatch(expected, actual, CollectionEqualCount(false))
+ assertTypeMatch(expected, actual, CollectionEqualCount(false))
+ }
+
+ @Test
+ fun testCollectionEqualCount_WithDefaultInit_CorrectlyFails() {
+ val expected = "{}"
+ val actual = """
+ {
+ "key1": 1
+ }
+ """
+
+ assertFailsWith("Validation should fail when collection counts are not equal") {
+ assertTypeMatch(expected, actual, CollectionEqualCount())
+ }
+ }
+
+ @Test
+ fun testKeyMustBeAbsent_WithDefaultInit_CorrectlyFails() {
+ val expected = "{}"
+ val actual = """
+ {
+ "key1": 1
+ }
+ """
+
+ assertFailsWith("Validation should fail when key name is present") {
+ assertTypeMatch(expected, actual, KeyMustBeAbsent("key1"))
+ }
+ }
+
+ @Test
+ fun testKeyMustBeAbsent_WithInnerPath_CorrectlyFails() {
+ val expected = "{}"
+ val actual = """
+ {
+ "events": [
+ {
+ "request": {
+ "path": "something"
+ }
+ }
+ ],
+ "path": "top level"
+ }
+ """
+
+ assertFailsWith("Validation should fail when key names not provided") {
+ assertTypeMatch(expected, actual, KeyMustBeAbsent("events[*].request.path"))
+ }
+ }
+
+ @Test
+ fun testKeyMustBeAbsent_WithSinglePath_Passes() {
+ val expected = "{}"
+ val actual = """
+ {
+ "key1": 1
+ }
+ """
+
+ assertExactMatch(expected, actual, KeyMustBeAbsent("key2"))
+ }
+
+ @Test
+ fun testKeyMustBeAbsent_WithMultipleKeys_Passes() {
+ val expected = "{}"
+ val actual = """
+ {
+ "key1": 1
+ }
+ """
+
+ assertExactMatch(expected, actual, KeyMustBeAbsent("key2", "key3"))
+ }
+
+ @Test
+ fun testKeyMustBeAbsent_Fails_WhenKeyPresent() {
+ val expected = "{}"
+ val actual = """
+ {
+ "key1": 1
+ }
+ """
+
+ assertFailsWith("Validation should fail when key that must be absent is present in actual") {
+ assertExactMatch(expected, actual, KeyMustBeAbsent("key1"))
+ }
+ }
+
+ @Test
+ fun testKeyMustBeAbsent_worksWhenKeyInDifferentHierarchy() {
+ val expected = """
+ {
+ "key1": 1
+ }
+ """
+ val actual = """
+ {
+ "key1": 1,
+ "key2": {
+ "key3": 1
+ }
+ }
+ """
+
+ assertFailsWith("Validation should fail when key that must be absent is present in actual") {
+ assertExactMatch(expected, actual, KeyMustBeAbsent("key2.key3"))
+ }
+ }
+
+ @Test
+ fun testValueExactMatch_WithDefaultPathsInit_CorrectlyFails() {
+ val expected = """
+ {
+ "key1": 1
+ }
+ """
+ val actual = """
+ {
+ "key1": 2
+ }
+ """
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch(expected, actual, ValueExactMatch(Subtree))
+ }
+ }
+
+ @Test
+ fun testValueTypeMatch_WithDefaultPathsInit_Passes() {
+ val expected = """
+ {
+ "key1": 1
+ }
+ """
+ val actual = """
+ {
+ "key1": 2
+ }
+ """
+
+ assertExactMatch(expected, actual, ValueTypeMatch(Subtree))
+ }
+
+ @Test
+ fun testValueTypeMatch_SubtreeOption_Propagates() {
+ val expected = """
+ {
+ "key0-0": [
+ {
+ "key1-0": 1
+ }
+ ]
+ }
+ """
+ val actual = """
+ {
+ "key0-0": [
+ {
+ "key1-0": 2
+ }
+ ]
+ }
+ """
+
+ assertExactMatch(expected, actual, ValueTypeMatch(Subtree, "key0-0"))
+ }
+
+ @Test
+ fun testValueTypeMatch_SingleNodeAndSubtreeOption() {
+ val expected = """
+ {
+ "key0-0": [
+ {
+ "key1-0": 1
+ }
+ ],
+ "key0-1": 1
+ }
+ """
+ val actual = """
+ {
+ "key0-0": [
+ {
+ "key1-0": 2
+ }
+ ],
+ "key0-1": 2
+ }
+ """
+
+ assertExactMatch(expected, actual, ValueTypeMatch("key0-1"), ValueTypeMatch(Subtree, "key0-0"))
+ }
+
+ @Test
+ fun testValueExactMatch_WithDefaultInit_CorrectlyFails() {
+ val expected = "[1, 2]"
+ val actual = "[2, 1]"
+
+ assertExactMatch(expected, actual, AnyOrderMatch())
+ }
+
+ @Test
+ fun testValueNotEqual_withDictionarySpecificKey_passes() {
+ val expected = """{ "key": "value" }"""
+ val actual = """{ "key": "different" }"""
+
+ assertExactMatch(expected, actual, ValueNotEqual("key"))
+ }
+
+ @Test
+ fun testValueNotEqual_withDictionaryUnsatisfiedKey_fails() {
+ val expected = """
+ {
+ "key1": "value",
+ "key2": "value"
+ }
+ """
+ val actual = """
+ {
+ "key1": "different",
+ "key2": "different"
+ }
+ """
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertExactMatch(expected, actual, ValueNotEqual("key1"))
+ }
+ }
+
+ @Test
+ fun testValueNotEqual_withDictionarySingleNodeScope_passes() {
+ val expected = """
+ {
+ "key1": "value",
+ "key2": {
+ "key3": "value"
+ }
+ }
+ """
+ val actual = """
+ {
+ "key1": "different",
+ "key2": {
+ "key3": "value"
+ }
+ }
+ """
+
+ assertExactMatch(expected, actual, ValueNotEqual("*"))
+ }
+
+ @Test
+ fun testValueNotEqual_withDictionarySubtreeScope_passes() {
+ val expected = """
+ {
+ "key1": "value",
+ "key2": {
+ "key3": "value"
+ }
+ }
+ """
+ val actual = """
+ {
+ "key1": "different",
+ "key2": {
+ "key3": "different"
+ }
+ }
+ """
+
+ assertExactMatch(expected, actual, ValueNotEqual(Subtree))
+ }
+
+ @Test
+ fun testValueNotEqual_withDictionaryWildcardKey_passes() {
+ val expected = """
+ {
+ "key1": "value",
+ "key2": "value"
+ }
+ """
+ val actual = """
+ {
+ "key1": "different",
+ "key2": "different"
+ }
+ """
+
+ assertExactMatch(expected, actual, ValueNotEqual("*"))
+ }
+
+ @Test
+ fun testValueNotEqual_withArraySpecificIndex_passes() {
+ val expected = "[1]"
+ val actual = "[2]"
+
+ assertExactMatch(expected, actual, ValueNotEqual("[0]"))
+ }
+
+ @Test
+ fun testValueNotEqual_withArrayUnsatisfiedIndex_passes() {
+ val expected = "[1, 1]"
+ val actual = "[2, 2]"
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertExactMatch(expected, actual, ValueNotEqual("[0]"))
+ }
+ }
+
+ @Test
+ fun testValueNotEqual_withArrayWildcardIndex_passes() {
+ val expected = "[1, 1]"
+ val actual = "[2, 2]"
+
+ assertExactMatch(expected, actual, ValueNotEqual("[*]"))
+ }
+
+ @Test
+ fun testValueNotEqual_withArraySingleNodeScope_passes() {
+ val expected = "[1, [1]]"
+ val actual = "[2, [1]]"
+
+ assertExactMatch(expected, actual, ValueNotEqual("*"))
+ }
+
+ @Test
+ fun testValueNotEqual_withArraySubtreeScope_passes() {
+ val expected = "[1, [1]]"
+ val actual = "[2, [2]]"
+
+ assertExactMatch(expected, actual, ValueNotEqual(Subtree))
+ }
+}
diff --git a/code/testutils/src/test/java/com/adobe/marketing/mobile/util/PathOptionElementCountTests.kt b/code/testutils/src/test/java/com/adobe/marketing/mobile/util/PathOptionElementCountTests.kt
new file mode 100644
index 000000000..589290a59
--- /dev/null
+++ b/code/testutils/src/test/java/com/adobe/marketing/mobile/util/PathOptionElementCountTests.kt
@@ -0,0 +1,688 @@
+/*
+ Copyright 2024 Adobe. All rights reserved.
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License. You may obtain a copy
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
+ Unless required by applicable law or agreed to in writing, software distributed under
+ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+ OF ANY KIND, either express or implied. See the License for the specific language
+ governing permissions and limitations under the License.
+*/
+
+package com.adobe.marketing.mobile.util
+
+import com.adobe.marketing.mobile.util.JSONAsserts.assertExactMatch
+import com.adobe.marketing.mobile.util.JSONAsserts.assertTypeMatch
+import com.adobe.marketing.mobile.util.NodeConfig.Scope.Subtree
+import org.junit.Test
+import kotlin.test.assertFailsWith
+
+class PathOptionElementCountTests {
+ @Test
+ fun testElementCount_withArray_passes() {
+ val actual = "[1, \"abc\", true, null]"
+
+ assertExactMatch("[]", actual, ElementCount(4))
+ assertTypeMatch("[]", actual, ElementCount(4))
+ }
+
+ @Test
+ fun testElementCount_withNestedArray_passes() {
+ val actual = "[1, [\"abc\", true, null]]"
+
+ assertExactMatch("[]", actual, ElementCount(4, Subtree))
+ assertTypeMatch("[]", actual, ElementCount(4, Subtree))
+ }
+
+ /**
+ * Validates that when child nodes are present, a subtree ElementCount that overlaps with those children
+ * does not propagate the element count requirement incorrectly.
+ * Also validates that this is true regardless of the order the path options are supplied.
+ */
+ @Test
+ fun testElementCount_withNestedArray_whenUnrelatedChildNodes_subtreeElementCountDoesNotPropagate_passes() {
+ val actual = """
+ [
+ 1,
+ [
+ "abc",
+ true,
+ null
+ ]
+ ]
+ """
+
+ assertExactMatch("[]", actual, AnyOrderMatch("[1]"), ElementCount(4, Subtree))
+ assertExactMatch("[]", actual, ElementCount(4, Subtree), AnyOrderMatch("[1]"))
+ assertTypeMatch("[]", actual, AnyOrderMatch("[1]"), ElementCount(4, Subtree))
+ assertTypeMatch("[]", actual, ElementCount(4, Subtree), AnyOrderMatch("[1]"))
+ }
+
+ /**
+ * Validates that when wildcard child nodes are present, a subtree ElementCount that overlaps with those children
+ * does not propagate the element count requirement incorrectly.
+ * Also validates that this is true regardless of the order the path options are supplied.
+ */
+ @Test
+ fun testElementCount_withNestedArray_whenWildcardChildNodes_subtreeElementCountDoesNotPropagate_passes() {
+ val actual = """
+ [
+ [
+ 1,
+ 2
+ ],
+ [
+ 1,
+ 2
+ ]
+ ]
+ """
+
+ // These test cases validate that the `ElementCount(4, Subtree)` requirement is not propagated
+ // to the wildcard set at the top level of the JSON hierarchy. If propagated incorrectly,
+ // for example, "key1" would also have a 4 element count assertion requirement.
+ assertExactMatch("[]", actual, AnyOrderMatch("[*]"), ElementCount(4, Subtree))
+ assertExactMatch("[]", actual, ElementCount(4, Subtree), AnyOrderMatch("[*]"))
+ assertTypeMatch("[]", actual, AnyOrderMatch("[*]"), ElementCount(4, Subtree))
+ assertTypeMatch("[]", actual, ElementCount(4, Subtree), AnyOrderMatch("[*]"))
+ }
+
+ /**
+ * Validates that when both specific and wildcard child nodes are present, a subtree ElementCount
+ * that overlaps with those children does not propagate the element count requirement incorrectly.
+ * Also validates that this is true regardless of the order the path options are supplied.
+ */
+ @Test
+ fun testElementCount_withNestedArray_whenAllChildNodes_subtreeElementCountDoesNotPropagate_passes() {
+ val actual = """
+ [
+ [
+ 1,
+ 2
+ ],
+ [
+ 1,
+ 2
+ ]
+ ]
+ """
+
+ // These test cases validate that the `ElementCount(4, Subtree)` requirement is not propagated
+ // to any of the child nodes. If propagated incorrectly, for example, "key1" would also have a
+ // 4 element count assertion requirement.
+ assertExactMatch("[]", actual, AnyOrderMatch("[*]"), AnyOrderMatch("[0]"), ElementCount(4, Subtree))
+ assertExactMatch("[]", actual, AnyOrderMatch("[*]"), ElementCount(4, Subtree), AnyOrderMatch("[0]"))
+ assertExactMatch("[]", actual, AnyOrderMatch("[0]"), AnyOrderMatch("[*]"), ElementCount(4, Subtree))
+ assertExactMatch("[]", actual, AnyOrderMatch("[0]"), ElementCount(4, Subtree), AnyOrderMatch("[*]"))
+ assertExactMatch("[]", actual, ElementCount(4, Subtree), AnyOrderMatch("[*]"), AnyOrderMatch("[0]"))
+ assertExactMatch("[]", actual, ElementCount(4, Subtree), AnyOrderMatch("[0]"), AnyOrderMatch("[*]"))
+
+ assertTypeMatch("[]", actual, AnyOrderMatch("[*]"), AnyOrderMatch("[0]"), ElementCount(4, Subtree))
+ assertTypeMatch("[]", actual, AnyOrderMatch("[*]"), ElementCount(4, Subtree), AnyOrderMatch("[0]"))
+ assertTypeMatch("[]", actual, AnyOrderMatch("[0]"), AnyOrderMatch("[*]"), ElementCount(4, Subtree))
+ assertTypeMatch("[]", actual, AnyOrderMatch("[0]"), ElementCount(4, Subtree), AnyOrderMatch("[*]"))
+ assertTypeMatch("[]", actual, ElementCount(4, Subtree), AnyOrderMatch("[*]"), AnyOrderMatch("[0]"))
+ assertTypeMatch("[]", actual, ElementCount(4, Subtree), AnyOrderMatch("[0]"), AnyOrderMatch("[*]"))
+ }
+
+ @Test
+ fun testElementCount_withNestedArray_whenSingleNodeScope_passes() {
+ val actual = "[1, [\"abc\", true, null]]"
+
+ assertExactMatch("[]", actual, ElementCount(1))
+ assertTypeMatch("[]", actual, ElementCount(1))
+ }
+
+ @Test
+ fun testElementCount_withNestedArray_whenSingleNodeScope_innerPath_passes() {
+ val actual = "[1, [\"abc\", true, null]]"
+
+ assertExactMatch("[]", actual, ElementCount(3, "[1]"))
+ assertTypeMatch("[]", actual, ElementCount(3, "[1]"))
+ }
+
+ @Test
+ fun testElementCount_withNestedArray_whenSimultaneousSingleNodeScope_passes() {
+ val actual = "[1, [\"abc\", true, null]]"
+
+ assertExactMatch("[]", actual, ElementCount(1), ElementCount(3, "[1]"))
+ assertTypeMatch("[]", actual, ElementCount(1), ElementCount(3, "[1]"))
+ }
+
+ @Test
+ fun testElementCount_withNestedArray_whenSimultaneousSingleNodeAndSubtreeScope_passes() {
+ val actual = "[1, [\"abc\", true, null]]"
+
+ assertExactMatch(
+ "[]",
+ actual,
+ ElementCount(1),
+ ElementCount(4, Subtree)
+ )
+ assertTypeMatch(
+ "[]",
+ actual,
+ ElementCount(1),
+ ElementCount(4, Subtree)
+ )
+ }
+
+ @Test
+ fun testElementCount_withArray_whenCountNotEqual_fails() {
+ val actual = "[1, \"abc\", true, null]"
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertExactMatch("[]", actual, ElementCount(5))
+ }
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertExactMatch("[]", actual, ElementCount(3))
+ }
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch("[]", actual, ElementCount(5))
+ }
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch("[]", actual, ElementCount(3))
+ }
+ }
+
+ @Test
+ fun testElementCount_withArray_whenSingleNodeDisabled_passes() {
+ val actual = "[1, \"abc\", true, null]"
+
+ assertExactMatch(
+ "[]",
+ actual,
+ ElementCount(3, Subtree),
+ ElementCount(null, false, "[0]")
+ )
+ assertTypeMatch(
+ "[]",
+ actual,
+ ElementCount(3, Subtree),
+ ElementCount(null, false, "[0]")
+ )
+ }
+
+ @Test
+ fun testElementCount_withNestedArray_whenMiddleCollectionDisablesElementCount_passes() {
+ val actual = "[1, [\"abc\", [true, null]]]"
+
+ assertExactMatch(
+ "[]",
+ actual,
+ ElementCount(3, Subtree),
+ ElementCount(null, false, "[1]")
+ )
+ assertTypeMatch(
+ "[]",
+ actual,
+ ElementCount(3, Subtree),
+ ElementCount(null, false, "[1]")
+ )
+ }
+
+ @Test
+ fun testElementCount_withNestedArray_whenNestedSandwichedSubtreeOverrides_passes() {
+ val actual = "[1, [\"abc\", [true, null]]]"
+
+ assertExactMatch(
+ "[]",
+ actual,
+ ElementCount(null, false, Subtree),
+ ElementCount(1, Subtree, "[1]"),
+ ElementCount(null, false, Subtree, "[1][1]")
+ )
+ assertTypeMatch(
+ "[]",
+ actual,
+ ElementCount(null, false, Subtree),
+ ElementCount(1, Subtree, "[1]"),
+ ElementCount(null, false, Subtree, "[1][1]")
+ )
+ }
+
+ @Test
+ fun testElementCount_withNestedArray_whenNestedSingleNodeOverrides_passes() {
+ val actual = "[1, [\"abc\", [true, null]]]"
+
+ assertExactMatch(
+ "[]",
+ actual,
+ ElementCount(null, false, Subtree),
+ ElementCount(1, "[1]")
+ )
+ assertTypeMatch(
+ "[]",
+ actual,
+ ElementCount(null, false, Subtree),
+ ElementCount(1, "[1]")
+ )
+ }
+
+ @Test
+ fun testElementCount_withNestedArray_whenNestedSubtreeOverrides_passes() {
+ val actual = "[1, [\"abc\", [true, null]]]"
+
+ assertExactMatch(
+ "[]",
+ actual,
+ ElementCount(null, false, Subtree),
+ ElementCount(3, Subtree, "[1]")
+ )
+ assertTypeMatch(
+ "[]",
+ actual,
+ ElementCount(null, false, Subtree),
+ ElementCount(3, Subtree, "[1]")
+ )
+ }
+
+ /**
+ * Counts are checked only at the collection level, so any ElementCount conditions placed on elements
+ * directly are ignored.
+ */
+ @Test
+ fun testElementCount_withArray_whenAppliedToElement_fails() {
+ val actual = "[1]"
+
+ assertFailsWith("Validation should fail when invalid path option is set") {
+ assertExactMatch("[]", actual, ElementCount(1, "[0]"))
+ }
+ assertFailsWith("Validation should fail when invalid path option is set") {
+ assertTypeMatch("[]", actual, ElementCount(1, "[0]"))
+ }
+ }
+
+ @Test
+ fun testElementCount_withArray_whenWildcard_passes() {
+ val actual = "[[1],[1]]"
+
+ assertExactMatch("[]", actual, ElementCount(1, "[*]"))
+ assertTypeMatch("[]", actual, ElementCount(1, "[*]"))
+ }
+
+ @Test
+ fun testElementCount_withDictionary_passes() {
+ val actual = """
+ {
+ "key1": 1,
+ "key2": "abc",
+ "key3": true,
+ "key4": null
+ }
+ """
+
+ assertExactMatch("{}", actual, ElementCount(4))
+ assertTypeMatch("{}", actual, ElementCount(4))
+ }
+
+ @Test
+ fun testElementCount_withNestedDictionary_passes() {
+ val actual = """
+ {
+ "key1": 1,
+ "key2": {
+ "key2_1": "abc",
+ "key2_2": true,
+ "key2_3": null
+ }
+ }
+ """
+
+ assertExactMatch("{}", actual, ElementCount(4, Subtree))
+ assertTypeMatch("{}", actual, ElementCount(4, Subtree))
+ }
+
+ /**
+ * Validates that when child nodes are present, a subtree ElementCount that overlaps with those children
+ * does not propagate the element count requirement incorrectly.
+ * Also validates that this is true regardless of the order the path options are supplied.
+ */
+ @Test
+ fun testElementCount_withNestedDictionary_whenUnrelatedChildNodes_subtreeElementCountDoesNotPropagate_passes() {
+ val actual = """
+ {
+ "key1": 1,
+ "key2": {
+ "key2_1": "abc",
+ "key2_2": true,
+ "key2_3": null
+ }
+ }
+ """
+
+ assertExactMatch("{}", actual, AnyOrderMatch("key1.key2"), ElementCount(4, Subtree))
+ assertExactMatch("{}", actual, ElementCount(4, Subtree), AnyOrderMatch("key1.key2"))
+ assertTypeMatch("{}", actual, AnyOrderMatch("key1.key2"), ElementCount(4, Subtree))
+ assertTypeMatch("{}", actual, ElementCount(4, Subtree), AnyOrderMatch("key1.key2"))
+ }
+
+ /**
+ * Validates that when wildcard child nodes are present, a subtree ElementCount that overlaps with those children
+ * does not propagate the element count requirement incorrectly.
+ * Also validates that this is true regardless of the order the path options are supplied.
+ */
+ @Test
+ fun testElementCount_withNestedDictionary_whenWildcardChildNodes_subtreeElementCountDoesNotPropagate_passes() {
+ val actual = """
+ {
+ "key1": {
+ "key3_1": 1,
+ "key3_2": 2
+ },
+ "key2": {
+ "key4_1": 1,
+ "key4_2": 2
+ }
+ }
+ """
+
+ // These test cases validate that the `ElementCount(4, Subtree)` requirement is not propagated
+ // to the wildcard set at the top level of the JSON hierarchy. If propagated incorrectly,
+ // for example, "key1" would also have a 4 element count assertion requirement.
+ assertExactMatch("{}", actual, AnyOrderMatch("*"), ElementCount(4, Subtree))
+ assertExactMatch("{}", actual, ElementCount(4, Subtree), AnyOrderMatch("*"))
+ assertTypeMatch("{}", actual, AnyOrderMatch("*"), ElementCount(4, Subtree))
+ assertTypeMatch("{}", actual, ElementCount(4, Subtree), AnyOrderMatch("*"))
+ }
+
+ /**
+ * Validates that when both specific and wildcard child nodes are present, a subtree ElementCount
+ * that overlaps with those children does not propagate the element count requirement incorrectly.
+ * Also validates that this is true regardless of the order the path options are supplied.
+ */
+ @Test
+ fun testElementCount_withNestedDictionary_whenAllChildNodes_subtreeElementCountDoesNotPropagate_passes() {
+ val actual = """
+ {
+ "key1": {
+ "key3_1": 1,
+ "key3_2": 2
+ },
+ "key2": {
+ "key4_1": 1,
+ "key4_2": 2
+ }
+ }
+ """
+
+ // These test cases validate that the `ElementCount(4, Subtree)` requirement is not propagated
+ // to any of the child nodes. If propagated incorrectly, for example, "key1" would also have a
+ // 4 element count assertion requirement.
+ assertExactMatch("{}", actual, AnyOrderMatch("*"), AnyOrderMatch("key1"), ElementCount(4, Subtree))
+ assertExactMatch("{}", actual, AnyOrderMatch("*"), ElementCount(4, Subtree), AnyOrderMatch("key1"))
+ assertExactMatch("{}", actual, AnyOrderMatch("key1"), AnyOrderMatch("*"), ElementCount(4, Subtree))
+ assertExactMatch("{}", actual, AnyOrderMatch("key1"), ElementCount(4, Subtree), AnyOrderMatch("*"))
+ assertExactMatch("{}", actual, ElementCount(4, Subtree), AnyOrderMatch("*"), AnyOrderMatch("key1"))
+ assertExactMatch("{}", actual, ElementCount(4, Subtree), AnyOrderMatch("key1"), AnyOrderMatch("*"))
+
+ assertTypeMatch("{}", actual, AnyOrderMatch("*"), AnyOrderMatch("key1"), ElementCount(4, Subtree))
+ assertTypeMatch("{}", actual, AnyOrderMatch("*"), ElementCount(4, Subtree), AnyOrderMatch("key1"))
+ assertTypeMatch("{}", actual, AnyOrderMatch("key1"), AnyOrderMatch("*"), ElementCount(4, Subtree))
+ assertTypeMatch("{}", actual, AnyOrderMatch("key1"), ElementCount(4, Subtree), AnyOrderMatch("*"))
+ assertTypeMatch("{}", actual, ElementCount(4, Subtree), AnyOrderMatch("*"), AnyOrderMatch("key1"))
+ assertTypeMatch("{}", actual, ElementCount(4, Subtree), AnyOrderMatch("key1"), AnyOrderMatch("*"))
+ }
+
+ @Test
+ fun testElementCount_withNestedDictionary_whenSingleNodeScope_passes() {
+ val actual = """
+ {
+ "key1": 1,
+ "key2": {
+ "key2_1": "abc",
+ "key2_2": true,
+ "key2_3": null
+ }
+ }
+ """
+
+ assertExactMatch("{}", actual, ElementCount(1))
+ assertTypeMatch("{}", actual, ElementCount(1))
+ }
+
+ @Test
+ fun testElementCount_withNestedDictionary_whenSingleNodeScope_innerPath_passes() {
+ val actual = """
+ {
+ "key1": 1,
+ "key2": {
+ "key2_1": "abc",
+ "key2_2": true,
+ "key2_3": null
+ }
+ }
+ """
+
+ assertExactMatch("{}", actual, ElementCount(3, "key2"))
+ assertTypeMatch("{}", actual, ElementCount(3, "key2"))
+ }
+
+ @Test
+ fun testElementCount_withNestedDictionary_whenSimultaneousSingleNodeScope_passes() {
+ val actual = """
+ {
+ "key1": 1,
+ "key2": {
+ "key2_1": "abc",
+ "key2_2": true,
+ "key2_3": null
+ }
+ }
+ """
+
+ assertExactMatch("{}", actual, ElementCount(1), ElementCount(3, "key2"))
+ assertTypeMatch("{}", actual, ElementCount(1), ElementCount(3, "key2"))
+ }
+
+ @Test
+ fun testElementCount_withNestedDictionary_whenSimultaneousSingleNodeAndSubtreeScope_passes() {
+ val actual = """
+ {
+ "key1": 1,
+ "key2": {
+ "key2_1": "abc",
+ "key2_2": true,
+ "key2_3": null
+ }
+ }
+ """
+
+ assertExactMatch(
+ "{}",
+ actual,
+ ElementCount(1),
+ ElementCount(4, Subtree)
+ )
+ assertTypeMatch(
+ "{}",
+ actual,
+ ElementCount(1),
+ ElementCount(4, Subtree)
+ )
+ }
+
+ @Test
+ fun testElementCount_withDictionary_whenCountNotEqual_fails() {
+ val actual = """
+ {
+ "key1": 1,
+ "key2": "abc",
+ "key3": true,
+ "key4": null
+ }
+ """
+
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertExactMatch("{}", actual, ElementCount(5))
+ }
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertExactMatch("{}", actual, ElementCount(3))
+ }
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch("{}", actual, ElementCount(5))
+ }
+ assertFailsWith("Validation should fail when path option is not satisfied") {
+ assertTypeMatch("{}", actual, ElementCount(3))
+ }
+ }
+
+ @Test
+ fun testElementCount_withDictionary_whenSingleNodeDisabled_passes() {
+ val actual = """
+ {
+ "key1": 1,
+ "key2": "abc",
+ "key3": true,
+ "key4": null
+ }
+ """
+
+ assertExactMatch(
+ "{}",
+ actual,
+ ElementCount(3, Subtree),
+ ElementCount(null, false, "key1")
+ )
+ assertTypeMatch(
+ "{}",
+ actual,
+ ElementCount(3, Subtree),
+ ElementCount(null, false, "key1")
+ )
+ }
+
+ @Test
+ fun testElementCount_withNestedDictionary_whenMiddleCollectionDisablesElementCount_passes() {
+ val actual = """
+ {
+ "key1": 1,
+ "key2": {
+ "key2_1": "abc",
+ "key2_2": {
+ "key3_1": true,
+ "key3_2": null
+ }
+ }
+ }
+ """
+
+ assertExactMatch(
+ "{}",
+ actual,
+ ElementCount(3, Subtree),
+ ElementCount(null, false, "key2")
+ )
+ assertTypeMatch(
+ "{}",
+ actual,
+ ElementCount(3, Subtree),
+ ElementCount(null, false, "key2")
+ )
+ }
+
+ @Test
+ fun testElementCount_withNestedDictionary_whenNestedSandwichedSubtreeOverrides_passes() {
+ val actual = """
+ {
+ "key1": 1,
+ "key2": {
+ "key2_1": "abc",
+ "key2_2": {
+ "key3_1": true,
+ "key3_2": null
+ }
+ }
+ }
+ """
+
+ assertExactMatch(
+ "{}",
+ actual,
+ ElementCount(null, false, Subtree),
+ ElementCount(1, Subtree, "key2"),
+ ElementCount(null, false, Subtree, "key2.key2_2")
+ )
+ assertTypeMatch(
+ "{}",
+ actual,
+ ElementCount(null, false, Subtree),
+ ElementCount(1, Subtree, "key2"),
+ ElementCount(null, false, Subtree, "key2.key2_2")
+ )
+ }
+
+ @Test
+ fun testElementCount_withNestedDictionary_whenNestedSingleNodeOverrides_passes() {
+ val actual = """
+ {
+ "key1": 1,
+ "key2": {
+ "key2_1": "abc",
+ "key2_2": {
+ "key3_1": true,
+ "key3_2": null
+ }
+ }
+ }
+ """
+
+ assertExactMatch(
+ "{}",
+ actual,
+ ElementCount(null, false, Subtree),
+ ElementCount(1, "key2"),
+ )
+ assertTypeMatch(
+ "{}",
+ actual,
+ ElementCount(null, false, Subtree),
+ ElementCount(1, "key2"),
+ )
+ }
+
+ @Test
+ fun testElementCount_withNestedDictionary_whenNestedSubtreeOverrides_passes() {
+ val actual = """
+ {
+ "key1": 1,
+ "key2": {
+ "key2_1": "abc",
+ "key2_2": {
+ "key3_1": true,
+ "key3_2": null
+ }
+ }
+ }
+ """
+
+ assertExactMatch(
+ "{}",
+ actual,
+ ElementCount(null, false, Subtree),
+ ElementCount(3, Subtree, "key2"),
+ )
+ assertTypeMatch(
+ "{}",
+ actual,
+ ElementCount(null, false, Subtree),
+ ElementCount(3, Subtree, "key2"),
+ )
+ }
+
+ /**
+ * Counts are checked only at the collection level, so any ElementCount conditions placed on elements
+ * directly are ignored.
+ */
+ @Test
+ fun testElementCount_withDictionary_whenAppliedToElement_fails() {
+ val actual = """{ "key1": 1 }"""
+
+ assertFailsWith("Validation should fail when invalid path option is set") {
+ assertExactMatch("{}", actual, ElementCount(1, "key1"))
+ }
+ assertFailsWith("Validation should fail when invalid path option is set") {
+ assertTypeMatch("{}", actual, ElementCount(1, "key1"))
+ }
+ }
+}
From 41c9a78ee00d4f72556b42417f09c4b18ea08d5c Mon Sep 17 00:00:00 2001
From: timkimadobe <95260439+timkimadobe@users.noreply.github.com>
Date: Thu, 7 Nov 2024 17:56:08 -0800
Subject: [PATCH 2/7] AEPTestUtils API dump
---
code/testutils/api/testutils.api | 706 +++++++++++++++++++++++++++++++
1 file changed, 706 insertions(+)
create mode 100644 code/testutils/api/testutils.api
diff --git a/code/testutils/api/testutils.api b/code/testutils/api/testutils.api
new file mode 100644
index 000000000..61162be8c
--- /dev/null
+++ b/code/testutils/api/testutils.api
@@ -0,0 +1,706 @@
+public class com/adobe/marketing/mobile/MobileCoreHelper {
+ public fun ()V
+ public static fun resetSDK ()V
+}
+
+public class com/adobe/marketing/mobile/services/MockDataStoreService : com/adobe/marketing/mobile/services/DataStoring {
+ public fun ()V
+ public static fun clearStores ()V
+ public fun getNamedCollection (Ljava/lang/String;)Lcom/adobe/marketing/mobile/services/NamedCollection;
+}
+
+public class com/adobe/marketing/mobile/services/MockDataStoreService$MockTestDataStore : com/adobe/marketing/mobile/services/NamedCollection {
+ public fun ()V
+ public fun contains (Ljava/lang/String;)Z
+ public fun getBoolean (Ljava/lang/String;Z)Z
+ public fun getDouble (Ljava/lang/String;D)D
+ public fun getFloat (Ljava/lang/String;F)F
+ public fun getInt (Ljava/lang/String;I)I
+ public fun getLong (Ljava/lang/String;J)J
+ public fun getMap (Ljava/lang/String;)Ljava/util/Map;
+ public fun getString (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
+ public fun remove (Ljava/lang/String;)V
+ public fun removeAll ()V
+ public fun setBoolean (Ljava/lang/String;Z)V
+ public fun setDouble (Ljava/lang/String;D)V
+ public fun setFloat (Ljava/lang/String;F)V
+ public fun setInt (Ljava/lang/String;I)V
+ public fun setLong (Ljava/lang/String;J)V
+ public fun setMap (Ljava/lang/String;Ljava/util/Map;)V
+ public fun setString (Ljava/lang/String;Ljava/lang/String;)V
+}
+
+public class com/adobe/marketing/mobile/services/NetworkServiceHelper : com/adobe/marketing/mobile/services/Networking {
+ public fun ()V
+ public fun connectAsync (Lcom/adobe/marketing/mobile/services/NetworkRequest;Lcom/adobe/marketing/mobile/services/NetworkCallback;)V
+}
+
+public class com/adobe/marketing/mobile/services/ServiceProviderHelper {
+ public fun ()V
+ public static fun cleanCacheDir ()V
+ public static fun cleanDatabaseDir ()V
+ public static fun resetServices ()V
+}
+
+public final class com/adobe/marketing/mobile/services/TestableNetworkRequest : com/adobe/marketing/mobile/services/NetworkRequest {
+ public static final field Companion Lcom/adobe/marketing/mobile/services/TestableNetworkRequest$Companion;
+ public fun (Ljava/lang/String;Lcom/adobe/marketing/mobile/services/HttpMethod;)V
+ public fun (Ljava/lang/String;Lcom/adobe/marketing/mobile/services/HttpMethod;[B)V
+ public fun (Ljava/lang/String;Lcom/adobe/marketing/mobile/services/HttpMethod;[BLjava/util/Map;)V
+ public fun (Ljava/lang/String;Lcom/adobe/marketing/mobile/services/HttpMethod;[BLjava/util/Map;I)V
+ public fun (Ljava/lang/String;Lcom/adobe/marketing/mobile/services/HttpMethod;[BLjava/util/Map;II)V
+ public synthetic fun (Ljava/lang/String;Lcom/adobe/marketing/mobile/services/HttpMethod;[BLjava/util/Map;IIILkotlin/jvm/internal/DefaultConstructorMarker;)V
+ public fun equals (Ljava/lang/Object;)Z
+ public static final fun from (Lcom/adobe/marketing/mobile/services/NetworkRequest;)Lcom/adobe/marketing/mobile/services/TestableNetworkRequest;
+ public final fun getBodyJson ()Lorg/json/JSONObject;
+ public fun hashCode ()I
+ public final fun queryParam (Ljava/lang/String;)Ljava/lang/String;
+}
+
+public final class com/adobe/marketing/mobile/services/TestableNetworkRequest$Companion {
+ public final fun from (Lcom/adobe/marketing/mobile/services/NetworkRequest;)Lcom/adobe/marketing/mobile/services/TestableNetworkRequest;
+}
+
+public final class com/adobe/marketing/mobile/testutils/BuildConfig {
+ public static final field BUILD_TYPE Ljava/lang/String;
+ public static final field DEBUG Z
+ public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String;
+ public fun ()V
+}
+
+public class com/adobe/marketing/mobile/util/ADBCountDownLatch {
+ public fun (I)V
+ public fun await ()V
+ public fun await (JLjava/util/concurrent/TimeUnit;)Z
+ public fun countDown ()V
+ public fun getCurrentCount ()I
+ public fun getInitialCount ()I
+ public fun toString ()Ljava/lang/String;
+}
+
+public final class com/adobe/marketing/mobile/util/AnyOrderMatch : com/adobe/marketing/mobile/util/MultiPathConfig {
+ public static final field Companion Lcom/adobe/marketing/mobile/util/AnyOrderMatch$Companion;
+ public fun ()V
+ public fun (Lcom/adobe/marketing/mobile/util/NodeConfig$Config;Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey;)V
+ public synthetic fun (Lcom/adobe/marketing/mobile/util/NodeConfig$Config;Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
+ public fun (Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;)V
+ public fun