From 8d9e7f4b71f9306dca56a9decc4bd4d20b8cc8f8 Mon Sep 17 00:00:00 2001 From: timkimadobe <95260439+timkimadobe@users.noreply.github.com> Date: Thu, 7 Nov 2024 17:55:49 -0800 Subject: [PATCH 1/7] Initial transfer of AEPTestUtils as new module --- code/settings.gradle.kts | 3 +- code/testutils/build.gradle.kts | 46 + code/testutils/src/main/AndroidManifest.xml | 19 + .../marketing/mobile/MobileCoreHelper.java | 25 + .../mobile/services/MockDataStoreService.java | 159 +++ .../mobile/services/NetworkServiceHelper.kt | 20 + .../services/ServiceProviderHelper.java | 85 ++ .../mobile/services/TestableNetworkRequest.kt | 137 ++ .../mobile/util/ADBCountDownLatch.java | 62 + .../mobile/util/FakeNamedCollection.java | 106 ++ .../marketing/mobile/util/JSONAsserts.kt | 987 +++++++++++++ .../marketing/mobile/util/MockConnection.java | 96 ++ .../mobile/util/MockNetworkService.kt | 328 +++++ .../mobile/util/MonitorExtension.java | 255 ++++ .../mobile/util/NetworkRequestHelper.kt | 268 ++++ .../adobe/marketing/mobile/util/NodeConfig.kt | 1131 +++++++++++++++ .../mobile/util/RealNetworkService.kt | 174 +++ .../marketing/mobile/util/TestConstants.java | 150 ++ .../marketing/mobile/util/TestHelper.java | 711 ++++++++++ .../mobile/util/TestPersistenceHelper.java | 70 + .../marketing/mobile/util/TestUtils.java | 117 ++ .../mobile/util/JSONAssertsHelpersTests.kt | 258 ++++ .../util/JSONAssertsParameterizedTests.kt | 531 +++++++ .../util/JSONAssertsPathOptionsTests.kt | 1228 +++++++++++++++++ .../util/PathOptionElementCountTests.kt | 688 +++++++++ 25 files changed, 7653 insertions(+), 1 deletion(-) create mode 100644 code/testutils/build.gradle.kts create mode 100644 code/testutils/src/main/AndroidManifest.xml create mode 100644 code/testutils/src/main/java/com/adobe/marketing/mobile/MobileCoreHelper.java create mode 100644 code/testutils/src/main/java/com/adobe/marketing/mobile/services/MockDataStoreService.java create mode 100644 code/testutils/src/main/java/com/adobe/marketing/mobile/services/NetworkServiceHelper.kt create mode 100644 code/testutils/src/main/java/com/adobe/marketing/mobile/services/ServiceProviderHelper.java create mode 100644 code/testutils/src/main/java/com/adobe/marketing/mobile/services/TestableNetworkRequest.kt create mode 100644 code/testutils/src/main/java/com/adobe/marketing/mobile/util/ADBCountDownLatch.java create mode 100644 code/testutils/src/main/java/com/adobe/marketing/mobile/util/FakeNamedCollection.java create mode 100644 code/testutils/src/main/java/com/adobe/marketing/mobile/util/JSONAsserts.kt create mode 100644 code/testutils/src/main/java/com/adobe/marketing/mobile/util/MockConnection.java create mode 100644 code/testutils/src/main/java/com/adobe/marketing/mobile/util/MockNetworkService.kt create mode 100644 code/testutils/src/main/java/com/adobe/marketing/mobile/util/MonitorExtension.java create mode 100644 code/testutils/src/main/java/com/adobe/marketing/mobile/util/NetworkRequestHelper.kt create mode 100644 code/testutils/src/main/java/com/adobe/marketing/mobile/util/NodeConfig.kt create mode 100644 code/testutils/src/main/java/com/adobe/marketing/mobile/util/RealNetworkService.kt create mode 100644 code/testutils/src/main/java/com/adobe/marketing/mobile/util/TestConstants.java create mode 100644 code/testutils/src/main/java/com/adobe/marketing/mobile/util/TestHelper.java create mode 100644 code/testutils/src/main/java/com/adobe/marketing/mobile/util/TestPersistenceHelper.java create mode 100644 code/testutils/src/main/java/com/adobe/marketing/mobile/util/TestUtils.java create mode 100644 code/testutils/src/test/java/com/adobe/marketing/mobile/util/JSONAssertsHelpersTests.kt create mode 100644 code/testutils/src/test/java/com/adobe/marketing/mobile/util/JSONAssertsParameterizedTests.kt create mode 100644 code/testutils/src/test/java/com/adobe/marketing/mobile/util/JSONAssertsPathOptionsTests.kt create mode 100644 code/testutils/src/test/java/com/adobe/marketing/mobile/util/PathOptionElementCountTests.kt diff --git a/code/settings.gradle.kts b/code/settings.gradle.kts index 309b06bb3..6f9174fa2 100644 --- a/code/settings.gradle.kts +++ b/code/settings.gradle.kts @@ -29,4 +29,5 @@ include ( ":macrobenchmark", ":microbenchmark", ":testapp", - ) \ No newline at end of file + ":testutils" + ) diff --git a/code/testutils/build.gradle.kts b/code/testutils/build.gradle.kts new file mode 100644 index 000000000..a5cbafa1a --- /dev/null +++ b/code/testutils/build.gradle.kts @@ -0,0 +1,46 @@ +/** + * 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. + */ + +import com.adobe.marketing.mobile.gradle.BuildConstants + +plugins { + id("aep-library") + id("binary-compatibility-validator") +} + +val coreExtensionVersion: String by project +val jacksonVersion = "2.12.7" + +aepLibrary { + namespace = "com.adobe.marketing.mobile.testutils" + moduleName = "testutils" + moduleVersion = "3.0.0" + enableSpotless = true + enableCheckStyle = true + enableSpotlessPrettierForJava = true + enableDokkaDoc = true + + publishing { + mavenRepoName = "AdobeMobileTestUtilsSdk" + mavenRepoDescription = "Android Test Utils for Adobe Mobile Marketing" + gitRepoName = "aepsdk-testutils-android" + addCoreDependency(coreExtensionVersion) + addMavenDependency("androidx.test.ext", "junit", BuildConstants.Versions.ANDROIDX_TEST_EXT_JUNIT) + addMavenDependency("com.fasterxml.jackson.core", "jackson-databind", jacksonVersion) + } +} + +dependencies { + implementation(project(":core")) + implementation(BuildConstants.Dependencies.ANDROIDX_TEST_EXT_JUNIT) + implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion") +} \ No newline at end of file diff --git a/code/testutils/src/main/AndroidManifest.xml b/code/testutils/src/main/AndroidManifest.xml new file mode 100644 index 000000000..5da93a23a --- /dev/null +++ b/code/testutils/src/main/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/code/testutils/src/main/java/com/adobe/marketing/mobile/MobileCoreHelper.java b/code/testutils/src/main/java/com/adobe/marketing/mobile/MobileCoreHelper.java new file mode 100644 index 000000000..6439c4446 --- /dev/null +++ b/code/testutils/src/main/java/com/adobe/marketing/mobile/MobileCoreHelper.java @@ -0,0 +1,25 @@ +/* + Copyright 2022 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; + +/** + * Helper class for testing to access methods provided in Core + */ +public class MobileCoreHelper { + + /** + * Wrapper around {@link MobileCore#resetSDK()} + */ + public static void resetSDK() { + MobileCore.resetSDK(); + } +} diff --git a/code/testutils/src/main/java/com/adobe/marketing/mobile/services/MockDataStoreService.java b/code/testutils/src/main/java/com/adobe/marketing/mobile/services/MockDataStoreService.java new file mode 100644 index 000000000..07599d2f3 --- /dev/null +++ b/code/testutils/src/main/java/com/adobe/marketing/mobile/services/MockDataStoreService.java @@ -0,0 +1,159 @@ +/* + 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.services; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +public class MockDataStoreService implements DataStoring { + + private static final ConcurrentMap stores = new ConcurrentHashMap<>(); + + public static void clearStores() { + stores.clear(); + } + + @Override + public NamedCollection getNamedCollection(String s) { + if (stores.containsKey(s)) { + return stores.get(s); + } + + NamedCollection newStore = new MockTestDataStore(); + stores.put(s, newStore); + return newStore; + } + + public static class MockTestDataStore implements NamedCollection { + + private final ConcurrentMap store; + + public MockTestDataStore() { + this.store = new ConcurrentHashMap<>(); + } + + @Override + public void setInt(String s, int i) { + store.put(s, i); + } + + @Override + public int getInt(String s, int i) { + if (store.containsKey(s)) { + return (int) store.get(s); + } + + return i; + } + + @Override + public void setString(String s, String s1) { + store.put(s, s1); + } + + @Override + public String getString(String s, String s1) { + if (store.containsKey(s)) { + return (String) store.get(s); + } + + return s1; + } + + @Override + public void setDouble(String s, double v) { + store.put(s, v); + } + + @Override + public double getDouble(String s, double v) { + if (store.containsKey(s)) { + return (double) store.get(s); + } + + return v; + } + + @Override + public void setLong(String s, long l) { + store.put(s, l); + } + + @Override + public long getLong(String s, long l) { + if (store.containsKey(s)) { + return (long) store.get(s); + } + + return l; + } + + @Override + public void setFloat(String s, float v) { + store.put(s, v); + } + + @Override + public float getFloat(String s, float v) { + if (store.containsKey(s)) { + return (float) store.get(s); + } + + return v; + } + + @Override + public void setBoolean(String s, boolean b) { + store.put(s, b); + } + + @Override + public boolean getBoolean(String s, boolean b) { + if (store.containsKey(s)) { + return (boolean) store.get(s); + } + + return b; + } + + @Override + public void setMap(String s, Map map) { + store.put(s, map); + } + + @Override + public Map getMap(String s) { + if (store.containsKey(s)) { + return (Map) store.get(s); + } + + return new HashMap<>(); + } + + @Override + public boolean contains(String s) { + return store.containsKey(s); + } + + @Override + public void remove(String s) { + store.remove(s); + } + + @Override + public void removeAll() { + store.clear(); + } + } +} diff --git a/code/testutils/src/main/java/com/adobe/marketing/mobile/services/NetworkServiceHelper.kt b/code/testutils/src/main/java/com/adobe/marketing/mobile/services/NetworkServiceHelper.kt new file mode 100644 index 000000000..a3e512315 --- /dev/null +++ b/code/testutils/src/main/java/com/adobe/marketing/mobile/services/NetworkServiceHelper.kt @@ -0,0 +1,20 @@ +/* + 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.services + +open class NetworkServiceHelper : Networking { + private val delegate: NetworkService = NetworkService() + + override fun connectAsync(request: NetworkRequest?, callback: NetworkCallback?) { + delegate.connectAsync(request, callback) + } +} diff --git a/code/testutils/src/main/java/com/adobe/marketing/mobile/services/ServiceProviderHelper.java b/code/testutils/src/main/java/com/adobe/marketing/mobile/services/ServiceProviderHelper.java new file mode 100644 index 000000000..b9994d966 --- /dev/null +++ b/code/testutils/src/main/java/com/adobe/marketing/mobile/services/ServiceProviderHelper.java @@ -0,0 +1,85 @@ +/* + 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.services; + +import android.content.Context; +import com.adobe.marketing.mobile.util.TestConstants; +import java.io.File; + +/** + * Helper class to give testing access to protected methods of the {@link ServiceProvider} class. + */ +public class ServiceProviderHelper { + + private static final String LOG_SOURCE = "ServiceProviderHelper"; + + /** + * Reset the {@link ServiceProvider}. + * @see ServiceProvider#resetServices() + */ + public static void resetServices() { + ServiceProvider.getInstance().resetServices(); + } + + /** + * Attempt to recursively delete all the files under the application cache directory. + * @see DeviceInforming#getApplicationCacheDir() + */ + public static void cleanCacheDir() { + File cacheDir = ServiceProvider.getInstance().getDeviceInfoService().getApplicationCacheDir(); + deleteFiles(cacheDir); + } + + /** + * Attempt to recursively delete all the files under the application database directory. + * Requires the application context to be set in the {@link ServiceProvider}. + * @see AppContextService#getApplicationContext() + */ + public static void cleanDatabaseDir() { + final String databaseName = TestConstants.EXTENSION_NAME; + Context appContext = ServiceProvider.getInstance().getAppContextService().getApplicationContext(); + if (appContext == null) { + Log.debug( + TestConstants.LOG_TAG, + LOG_SOURCE, + "Failed to clean database directory for (%s), the ApplicationContext is null", + databaseName + ); + return; + } + + final File databaseDirDataQueue = appContext.getDatabasePath(databaseName); + if (databaseDirDataQueue != null) { + deleteFiles(databaseDirDataQueue.getParentFile()); + } + } + + /** + * Recursively delete all files under the given {@code directory}. + * @param directory the directory to clean of files + */ + private static void deleteFiles(final File directory) { + if (directory == null) { + return; + } + + for (File f : directory.listFiles()) { + if (f.isDirectory()) { + deleteFiles(f); + } + + boolean wasDeleted = f.delete(); + String msg = wasDeleted ? "Successfully deleted cache file/folder " : "Unable to delete cache file/folder "; + Log.debug(TestConstants.LOG_TAG, LOG_SOURCE, msg + "'" + f.getName() + "'"); + } + } +} diff --git a/code/testutils/src/main/java/com/adobe/marketing/mobile/services/TestableNetworkRequest.kt b/code/testutils/src/main/java/com/adobe/marketing/mobile/services/TestableNetworkRequest.kt new file mode 100644 index 000000000..b1f820b77 --- /dev/null +++ b/code/testutils/src/main/java/com/adobe/marketing/mobile/services/TestableNetworkRequest.kt @@ -0,0 +1,137 @@ +/* + Copyright 2022 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.services + +import com.adobe.marketing.mobile.util.TestConstants +import org.json.JSONException +import org.json.JSONObject +import java.net.MalformedURLException +import java.net.URL + +/** + * A NetworkRequest conforming class that provides additional functionality that is helpful in testing + * scenarios. + */ +class TestableNetworkRequest @JvmOverloads constructor( + url: String, + method: HttpMethod, + body: ByteArray? = null, + headers: Map? = null, + connectTimeout: Int = 5, + readTimeout: Int = 5 +) : NetworkRequest(url, method, body, headers, connectTimeout, readTimeout) { + + private val queryParamMap: Map = splitQueryParameters(url) + + companion object { + private const val LOG_SOURCE = "TestableNetworkRequest" + + /** + * Creates an instance of [TestableNetworkRequest] from a given [NetworkRequest]. + * If the provided [NetworkRequest] is null, this method returns null. + * + * @param request The [NetworkRequest] to convert into a [TestableNetworkRequest]. + * Can be null. + * @return A new instance of [TestableNetworkRequest] if [request] is not null; + * otherwise, null. + */ + @JvmStatic + fun from(request: NetworkRequest?): TestableNetworkRequest? { + return if (request == null) { + null + } else { + TestableNetworkRequest( + request.url, + request.method, + request.body, + request.headers, + request.connectTimeout, + request.readTimeout + ) + } + } + + private fun splitQueryParameters(url: String): Map { + val queryParamMap = mutableMapOf() + try { + val urlObj = URL(url) + urlObj.query?.let { query -> + val pairs = query.split("&") + for (pair in pairs) { + val index = pair.indexOf("=") + if (index > 0) { + queryParamMap[pair.substring(0, index)] = pair.substring(index + 1) + } + } + } + } catch (e: MalformedURLException) { + Log.warning(TestConstants.LOG_TAG, LOG_SOURCE, "Failed to decode Network Request URL '$url'") + } + return queryParamMap + } + } + + fun queryParam(key: String): String? { + return queryParamMap[key] + } + + /** + * Two [TestableNetworkRequest]/[NetworkRequest]s are equal if: + * 1. Their URLs have the same: protocol, host and path (*excluding* query parameters) + * 2. Use the same [HttpMethod]. + * + * @param other the other [TestableNetworkRequest] to compare to + * @return true if the provided request is equal to this instance + */ + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is NetworkRequest) return false + + if (this.method != other.method) return false + + if (this.url == null && other.url == null) return true + + return try { + val thisUrl = URL(this.url) + val otherUrl = URL(other.url) + thisUrl.protocol == otherUrl.protocol && + thisUrl.host == otherUrl.host && + thisUrl.path == otherUrl.path + } catch (e: MalformedURLException) { + false + } + } + + override fun hashCode(): Int { + return try { + val url = URL(this.url) + listOf(url.protocol, url.host, url.path, this.method).hashCode() + } catch (e: MalformedURLException) { + listOf(this.url, this.method).hashCode() + } + } + + /** + * Converts the body of the [TestableNetworkRequest] into a [JSONObject]. + * + * @return A [JSONObject] representation of the body if it is valid JSON, otherwise null. + */ + fun getBodyJson(): JSONObject? { + val payload = body?.let { String(it) } ?: return null + return try { + JSONObject(payload) + } catch (e: JSONException) { + Log.warning(TestConstants.LOG_TAG, LOG_SOURCE, "Failed to create JSONObject from body with error: ${e.message}") + null + } + } +} diff --git a/code/testutils/src/main/java/com/adobe/marketing/mobile/util/ADBCountDownLatch.java b/code/testutils/src/main/java/com/adobe/marketing/mobile/util/ADBCountDownLatch.java new file mode 100644 index 000000000..93196ad10 --- /dev/null +++ b/code/testutils/src/main/java/com/adobe/marketing/mobile/util/ADBCountDownLatch.java @@ -0,0 +1,62 @@ +/* + 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 androidx.annotation.NonNull; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Testing helper class, wrapper over {@link CountDownLatch} + */ +public class ADBCountDownLatch { + + private final CountDownLatch latch; + private final int initialCount; + private final AtomicInteger currentCount; + + public ADBCountDownLatch(final int expectedCount) { + this.initialCount = expectedCount; + this.latch = new CountDownLatch(expectedCount); + this.currentCount = new AtomicInteger(); + } + + public void await() throws InterruptedException { + latch.await(); + } + + public boolean await(long timeout, TimeUnit unit) throws InterruptedException { + return latch.await(timeout, unit); + } + + /** + * This API should be called from the worker threads, to allow the await API to block on execution + */ + public void countDown() { + currentCount.incrementAndGet(); + latch.countDown(); + } + + public int getInitialCount() { + return initialCount; + } + + public int getCurrentCount() { + return currentCount.get(); + } + + @NonNull @Override + public String toString() { + return String.format("%s, initial: %d, current: %d", latch, initialCount, currentCount.get()); + } +} diff --git a/code/testutils/src/main/java/com/adobe/marketing/mobile/util/FakeNamedCollection.java b/code/testutils/src/main/java/com/adobe/marketing/mobile/util/FakeNamedCollection.java new file mode 100644 index 000000000..e1fdb6fd0 --- /dev/null +++ b/code/testutils/src/main/java/com/adobe/marketing/mobile/util/FakeNamedCollection.java @@ -0,0 +1,106 @@ +/* + 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 java.util.HashMap; +import java.util.Map; + +public class FakeNamedCollection implements NamedCollection { + + private HashMap dataStore = new HashMap<>(); + + @Override + public void setInt(String key, int val) { + dataStore.put(key, val); + } + + @Override + public int getInt(String key, int fallback) { + return DataReader.optInt(dataStore, key, fallback); + } + + @Override + public void setString(String key, String val) { + dataStore.put(key, val); + } + + @Override + public String getString(String key, String fallback) { + return DataReader.optString(dataStore, key, fallback); + } + + @Override + public void setDouble(String key, double val) { + dataStore.put(key, val); + } + + @Override + public double getDouble(String key, double fallback) { + return DataReader.optDouble(dataStore, key, fallback); + } + + @Override + public void setLong(String key, long val) { + dataStore.put(key, val); + } + + @Override + public long getLong(String key, long fallback) { + return DataReader.optLong(dataStore, key, fallback); + } + + @Override + public void setFloat(String key, float val) { + dataStore.put(key, val); + } + + @Override + public float getFloat(String key, float fallback) { + return DataReader.optFloat(dataStore, key, fallback); + } + + @Override + public void setBoolean(String key, boolean val) { + dataStore.put(key, val); + } + + @Override + public boolean getBoolean(String key, boolean fallback) { + return DataReader.optBoolean(dataStore, key, fallback); + } + + @Override + public void setMap(String key, Map val) { + dataStore.put(key, val); + } + + @Override + public Map getMap(String key) { + return DataReader.optTypedMap(String.class, dataStore, key, null); + } + + @Override + public boolean contains(String key) { + return dataStore.containsKey(key); + } + + @Override + public void remove(String key) { + dataStore.remove(key); + } + + @Override + public void removeAll() { + dataStore = new HashMap<>(); + } +} diff --git a/code/testutils/src/main/java/com/adobe/marketing/mobile/util/JSONAsserts.kt b/code/testutils/src/main/java/com/adobe/marketing/mobile/util/JSONAsserts.kt new file mode 100644 index 000000000..61d267c58 --- /dev/null +++ b/code/testutils/src/main/java/com/adobe/marketing/mobile/util/JSONAsserts.kt @@ -0,0 +1,987 @@ +/* + 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 androidx.annotation.VisibleForTesting +import com.adobe.marketing.mobile.util.NodeConfig.OptionKey.AnyOrderMatch +import com.adobe.marketing.mobile.util.NodeConfig.OptionKey.ElementCount +import com.adobe.marketing.mobile.util.NodeConfig.OptionKey.KeyMustBeAbsent +import com.adobe.marketing.mobile.util.NodeConfig.OptionKey.PrimitiveExactMatch +import com.adobe.marketing.mobile.util.NodeConfig.Scope.SingleNode +import com.adobe.marketing.mobile.util.NodeConfig.Scope.Subtree +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail + +object JSONAsserts { + data class ValidationResult(val isValid: Boolean, val elementCount: Int) + + /** + * Asserts exact equality between two [JSONObject] or [JSONArray] instances. + * + * @param expected The expected [JSONObject] or [JSONArray] to compare. + * @param actual The actual [JSONObject] or [JSONArray] to compare. + * + * @throws AssertionError If the [expected] and [actual] JSON structures are not exactly equal. + */ + @JvmStatic + fun assertEquals(expected: Any?, actual: Any?) { + if (expected == null && actual == null) { + return + } + if (expected == null || actual == null) { + fail( + """ + ${if (expected == null) "Expected is null" else "Actual is null"} and + ${if (expected == null) "Actual" else "Expected"} is non-null. + + Expected: $expected + + Actual: $actual + """.trimIndent() + ) + return + } + // Exact equality is just a special case of exact match + assertExactMatch(expected, actual, CollectionEqualCount(true, NodeConfig.Scope.Subtree)) + } + + /** + * Performs JSON validation where only the values from the `expected` JSON are required by default. + * By default, the comparison logic uses the value type match option, only validating that both values are of the same type. + * + * Both objects and arrays use extensible collections by default, meaning that only the elements in `expected` are + * validated. + * + * Path options allow for powerful customizations to the comparison logic; see structs conforming to [MultiPathConfig]: + * - [AnyOrderMatch] + * - [CollectionEqualCount] + * - [KeyMustBeAbsent] + * - [ValueExactMatch], [ValueTypeMatch] + * + * For example, given an expected JSON like: + * ``` + * { + * "key1": "value1", + * "key2": [{ "nest1": 1}, {"nest2": 2}], + * "key3": { "key4": 1 }, + * "key.name": 1, + * "key[123]": 1 + * } + * ``` + * An example path for this JSON would be: `"key2[1].nest2"`. + * + * Paths must begin from the top level of the expected JSON. Multiple paths and path options can be used at the same time. + * Path options are applied sequentially. If an option overrides an existing one, the overriding will occur in the order in which + * the path options are specified. + * + * Formats for object keys: + * - Standard keys - The key name itself: `"key1"` + * - Nested keys - Use dot notation: `"key3.key4"`. + * - Keys with dots in the name: Escape the dot notation with a backslash: `"key\.name"`. + * + * Formats for arrays: + * - Standard index - The index integer inside square brackets: `[]` (e.g., `[0]`, `[28]`). + * - Keys with array brackets in the name - Escape the brackets with backslashes: `key\[123\]`. + * + * Formats for wildcard object key and array index names: + * - Array wildcard - All children elements of the array: `[*]` (ex: `key1[*].key3`) + * - Object wildcard - All children elements of the object: `*` (ex: `key1.*.key3`) + * - Key whose name is asterisk - Escape the asterisk with backslash: `"\*"` + * - Note that wildcard path options also apply to any existing specific nodes at the same level. + * + * - Parameters: + * - expected: The expected JSON ([JSONObject], [JSONArray], or types supported by [getJSONRepresentation]) to compare. + * - actual: The actual JSON ([JSONObject], [JSONArray], or types supported by [getJSONRepresentation]) to compare. + * - pathOptions: The path options to use in the validation process. + */ + @JvmStatic + fun assertTypeMatch(expected: Any, actual: Any?, pathOptions: List) { + val treeDefaults = listOf( + AnyOrderMatch(isActive = false), + CollectionEqualCount(isActive = false), + // ElementCount subtree default is set to `true` so that all elements are counted by default + ElementCount(null, isActive = true), + KeyMustBeAbsent(isActive = false), + ValueTypeMatch(), + ValueNotEqual(isActive = false) + ) + validate(expected, actual, pathOptions.toList(), treeDefaults) + } + + /** + * Performs JSON validation where only the values from the `expected` JSON are required by default. + * By default, the comparison logic uses the value type match option, only validating that both values are of the same type. + * + * Both objects and arrays use extensible collections by default, meaning that only the elements in `expected` are + * validated. + * + * Path options allow for powerful customizations to the comparison logic; see structs conforming to [MultiPathConfig]: + * - [AnyOrderMatch] + * - [CollectionEqualCount] + * - [KeyMustBeAbsent] + * - [ValueExactMatch], [ValueTypeMatch] + * + * For example, given an expected JSON like: + * ``` + * { + * "key1": "value1", + * "key2": [{ "nest1": 1}, {"nest2": 2}], + * "key3": { "key4": 1 }, + * "key.name": 1, + * "key[123]": 1 + * } + * ``` + * An example path for this JSON would be: `"key2[1].nest2"`. + * + * Paths must begin from the top level of the expected JSON. Multiple paths and path options can be used at the same time. + * Path options are applied sequentially. If an option overrides an existing one, the overriding will occur in the order in which + * the path options are specified. + * + * Formats for object keys: + * - Standard keys - The key name itself: `"key1"` + * - Nested keys - Use dot notation: `"key3.key4"`. + * - Keys with dots in the name: Escape the dot notation with a backslash: `"key\.name"`. + * + * Formats for arrays: + * - Standard index - The index integer inside square brackets: `[]` (e.g., `[0]`, `[28]`). + * - Keys with array brackets in the name - Escape the brackets with backslashes: `key\[123\]`. + * + * Formats for wildcard object key and array index names: + * - Array wildcard - All children elements of the array: `[*]` (ex: `key1[*].key3`) + * - Object wildcard - All children elements of the object: `*` (ex: `key1.*.key3`) + * - Key whose name is asterisk - Escape the asterisk with backslash: `"\*"` + * - Note that wildcard path options also apply to any existing specific nodes at the same level. + * + * - Parameters: + * - expected: The expected JSON ([JSONObject], [JSONArray], or types supported by [getJSONRepresentation]) to compare. + * - actual: The actual JSON ([JSONObject], [JSONArray], or types supported by [getJSONRepresentation]) to compare. + * - pathOptions: The path options to use in the validation process. + */ + @JvmStatic + fun assertTypeMatch(expected: Any, actual: Any?, vararg pathOptions: MultiPathConfig) { + assertTypeMatch(expected, actual, pathOptions.toList()) + } + + /** + * Performs JSON validation where only the values from the `expected` JSON are required by default. + * By default, the comparison logic uses the value exact match option, validating that both values are of the same type + * **and** have the same literal value. + * + * Both objects and arrays use extensible collections by default, meaning that only the elements in `expected` are + * validated. + * + * Path options allow for powerful customizations to the comparison logic; see structs conforming to [MultiPathConfig]: + * - [AnyOrderMatch] + * - [CollectionEqualCount] + * - [KeyMustBeAbsent] + * - [ValueExactMatch], [ValueTypeMatch] + * + * For example, given an expected JSON like: + * ``` + * { + * "key1": "value1", + * "key2": [{ "nest1": 1}, {"nest2": 2}], + * "key3": { "key4": 1 }, + * "key.name": 1, + * "key[123]": 1 + * } + * ``` + * An example path for this JSON would be: `"key2[1].nest2"`. + * + * Paths must begin from the top level of the expected JSON. Multiple paths and path options can be used at the same time. + * Path options are applied sequentially. If an option overrides an existing one, the overriding will occur in the order in which + * the path options are specified. + * + * Formats for object keys: + * - Standard keys - The key name itself: `"key1"` + * - Nested keys - Use dot notation: `"key3.key4"`. + * - Keys with dots in the name: Escape the dot notation with a backslash: `"key\.name"`. + * + * Formats for arrays: + * - Standard index - The index integer inside square brackets: `[]` (e.g., `[0]`, `[28]`). + * - Keys with array brackets in the name - Escape the brackets with backslashes: `key\[123\]`. + * + * Formats for wildcard object key and array index names: + * - Array wildcard - All children elements of the array: `[*]` (ex: `key1[*].key3`) + * - Object wildcard - All children elements of the object: `*` (ex: `key1.*.key3`) + * - Key whose name is asterisk - Escape the asterisk with backslash: `"\*"` + * - Note that wildcard path options also apply to any existing specific nodes at the same level. + * + * - Parameters: + * - expected: The expected JSON ([JSONObject], [JSONArray], or types supported by [getJSONRepresentation]) to compare. + * - actual: The actual JSON ([JSONObject], [JSONArray], or types supported by [getJSONRepresentation]) to compare. + * - pathOptions: The path options to use in the validation process. + */ + @JvmStatic + fun assertExactMatch(expected: Any, actual: Any?, pathOptions: List) { + val treeDefaults = listOf( + AnyOrderMatch(isActive = false), + CollectionEqualCount(isActive = false), + // ElementCount subtree default is set to `true` so that all elements are counted by default + ElementCount(null, isActive = true), + KeyMustBeAbsent(isActive = false), + ValueExactMatch(), + ValueNotEqual(isActive = false) + ) + validate(expected, actual, pathOptions.toList(), treeDefaults) + } + + /** + * Performs JSON validation where only the values from the `expected` JSON are required by default. + * By default, the comparison logic uses the value exact match option, validating that both values are of the same type + * **and** have the same literal value. + * + * Both objects and arrays use extensible collections by default, meaning that only the elements in `expected` are + * validated. + * + * Path options allow for powerful customizations to the comparison logic; see structs conforming to [MultiPathConfig]: + * - [AnyOrderMatch] + * - [CollectionEqualCount] + * - [KeyMustBeAbsent] + * - [ValueExactMatch], [ValueTypeMatch] + * + * For example, given an expected JSON like: + * ``` + * { + * "key1": "value1", + * "key2": [{ "nest1": 1}, {"nest2": 2}], + * "key3": { "key4": 1 }, + * "key.name": 1, + * "key[123]": 1 + * } + * ``` + * An example path for this JSON would be: `"key2[1].nest2"`. + * + * Paths must begin from the top level of the expected JSON. Multiple paths and path options can be used at the same time. + * Path options are applied sequentially. If an option overrides an existing one, the overriding will occur in the order in which + * the path options are specified. + * + * Formats for object keys: + * - Standard keys - The key name itself: `"key1"` + * - Nested keys - Use dot notation: `"key3.key4"`. + * - Keys with dots in the name: Escape the dot notation with a backslash: `"key\.name"`. + * + * Formats for arrays: + * - Standard index - The index integer inside square brackets: `[]` (e.g., `[0]`, `[28]`). + * - Keys with array brackets in the name - Escape the brackets with backslashes: `key\[123\]`. + * + * Formats for wildcard object key and array index names: + * - Array wildcard - All children elements of the array: `[*]` (ex: `key1[*].key3`) + * - Object wildcard - All children elements of the object: `*` (ex: `key1.*.key3`) + * - Key whose name is asterisk - Escape the asterisk with backslash: `"\*"` + * - Note that wildcard path options also apply to any existing specific nodes at the same level. + * + * - Parameters: + * - expected: The expected JSON ([JSONObject], [JSONArray], or types supported by [getJSONRepresentation]) to compare. + * - actual: The actual JSON ([JSONObject], [JSONArray], or types supported by [getJSONRepresentation]) to compare. + * - pathOptions: The path options to use in the validation process. + */ + @JvmStatic + fun assertExactMatch(expected: Any, actual: Any?, vararg pathOptions: MultiPathConfig) { + assertExactMatch(expected, actual, pathOptions.toList()) + } + + private fun validate( + expected: Any, + actual: Any?, + pathOptions: List, + treeDefaults: List + ) { + try { + val nodeTree = generateNodeTree(pathOptions, treeDefaults) + + val expectedJSON = getJSONRepresentation(expected) + if (expectedJSON == null) { + fail("Failed to convert expected to valid JSON representation.") + } + val actualJSON = getJSONRepresentation(actual) + + validateActual(actualJSON, nodeTree = nodeTree) + validateJSON(expectedJSON, actualJSON, nodeTree = nodeTree) + } catch (e: java.lang.IllegalArgumentException) { + fail("Invalid JSON provided: ${e.message}") + } + } + + /** + * Performs a customizable validation between the given `expected` and `actual` values, using the configured options. + * In case of a validation failure **and** if `shouldAssert` is `true`, a test failure occurs. + * + * @param expected The expected value to compare. + * @param actual The actual value to compare. + * @param keyPath A list of keys or array indexes representing the path to the current value being compared. Defaults to an empty list. + * @param nodeTree A tree of configuration objects used to control various validation settings. + * @param shouldAssert Indicates if an assertion error should be thrown if `expected` and `actual` are not equal. Defaults to `true`. + * @return `true` if `expected` and `actual` are equal based on the settings in `nodeTree`, otherwise returns `false`. + */ + private fun validateJSON( + expected: Any?, + actual: Any?, + keyPath: List = emptyList(), + nodeTree: NodeConfig, + shouldAssert: Boolean = true + ): Boolean { + if (expected == null || expected == JSONObject.NULL) { + return true + } + if (actual == null || actual == JSONObject.NULL) { + if (shouldAssert) { + fail( + """ + Expected JSON is non-null but Actual JSON is null. + Expected: $expected + Actual: $actual + Key path: ${keyPathAsString(keyPath)} + """.trimIndent() + ) + } + return false + } + + return when { + expected is String && actual is String || + expected is Boolean && actual is Boolean || + expected is Int && actual is Int || + expected is Double && actual is Double -> { + if (nodeTree.valueNotEqual.isActive) { + if (shouldAssert) assertNotEquals( + """ + Expected and Actual values should not be equal. + Expected: $expected (Type: ${expected::class.qualifiedName}) + Actual: $actual (Type: ${actual::class.qualifiedName}) + Key path: ${keyPathAsString(keyPath)} + """.trimIndent(), + expected, + actual + ) + expected != actual + } else if (nodeTree.primitiveExactMatch.isActive) { + if (shouldAssert) assertEquals( + "Key path: ${keyPathAsString(keyPath)}", + expected, + actual + ) + expected == actual + } else { + true + } + } + expected is JSONObject && actual is JSONObject -> validateJSON( + expected as? JSONObject, + actual as? JSONObject, + keyPath, + nodeTree, + shouldAssert + ) + expected is JSONArray && actual is JSONArray -> validateJSON( + expected as? JSONArray, + actual as? JSONArray, + keyPath, + nodeTree, + shouldAssert + ) + else -> { + if (shouldAssert) { + fail( + """ + Expected and Actual types are not the same. + Expected: $expected (Type: ${expected::class.qualifiedName}) + Actual: $actual (Type: ${actual::class.qualifiedName}) + Key path: ${keyPathAsString(keyPath)} + """.trimIndent() + ) + } + false + } + } + } + + /** + * Performs a customizable validation between the given `expected` and `actual` arrays, using the configured options. + * In case of a validation failure **and** if `shouldAssert` is `true`, a test failure occurs. + * + * @param expected The expected array to compare. + * @param actual The actual array to compare. + * @param keyPath A list of keys or array indexes representing the path to the current value being compared. + * @param nodeTree A tree of configuration objects used to control various validation settings. + * @param shouldAssert Indicates if an assertion error should be thrown if `expected` and `actual` are not equal. + * @return `true` if `expected` and `actual` are equal based on the settings in `nodeTree`, otherwise returns `false`. + */ + private fun validateJSON( + expected: JSONArray?, + actual: JSONArray?, + keyPath: List, + nodeTree: NodeConfig, + shouldAssert: Boolean = true + ): Boolean { + if (expected == null) { + return true + } + if (actual == null) { + if (shouldAssert) { + fail( + """ + Expected JSON is non-null but Actual JSON is null. + + Expected: $expected + Actual: $actual + + Key path: ${keyPathAsString(keyPath)} + """.trimIndent() + ) + } + return false + } + + if (nodeTree.collectionEqualCount.isActive) { + if (expected.length() != actual.length()) { + if (shouldAssert) { + fail( + """ + Expected JSON count does not match Actual JSON. + + Expected count: ${expected.length()} + Actual count: ${actual.length()} + + Expected: $expected + Actual: $actual + + Key path: ${keyPathAsString(keyPath)} + """.trimIndent() + ) + } + return false + } + } else if (expected.length() > actual.length()) { + if (shouldAssert) { + fail( + """ + Expected JSON has more elements than Actual JSON. + + Expected count: ${expected.length()} + Actual count: ${actual.length()} + + Expected: $expected + Actual: $actual + + Key path: ${keyPathAsString(keyPath)} + """.trimIndent() + ) + } + return false + } + + val expectedIndexes = (0 until expected.length()).associate { index -> + index.toString() to NodeConfig.resolveOption(AnyOrderMatch, index, nodeTree) + }.toMutableMap() + val anyOrderIndexes = expectedIndexes.filter { it.value.isActive } + + for (key in anyOrderIndexes.keys) { + expectedIndexes.remove(key) + } + + val availableWildcardActualIndexes = mutableSetOf().apply { + addAll((0 until actual.length()).map { it.toString() }) + removeAll(expectedIndexes.keys) + } + + var validationResult = true + + for ((index, config) in expectedIndexes) { + val intIndex = index.toInt() + validationResult = validateJSON( + expected[intIndex], + actual.opt(intIndex), + keyPath + intIndex, + nodeTree.getNextNode(index), + shouldAssert + ) && validationResult + } + + for ((index, config) in anyOrderIndexes) { + val intIndex = index.toInt() + + val actualIndex = availableWildcardActualIndexes.firstOrNull { + validateJSON( + expected[intIndex], + actual.opt(it.toInt()), + keyPath + intIndex, + nodeTree.getNextNode(index), + shouldAssert = false + ) + } + if (actualIndex == null) { + if (shouldAssert) { + fail( + """ + Wildcard ${if (NodeConfig.resolveOption(PrimitiveExactMatch, index, nodeTree).isActive) "exact" else "type"} + match found no matches on Actual side satisfying the Expected requirement. + + Requirement: $nodeTree + + Expected: ${expected[intIndex]} + Actual (remaining unmatched elements): ${availableWildcardActualIndexes.map { actual[it.toInt()] }} + + Key path: ${keyPathAsString(keyPath)} + """.trimIndent() + ) + } + validationResult = false + break + } else { + availableWildcardActualIndexes.remove(actualIndex) + } + } + return validationResult + } + + /** + * Performs a customizable validation between the given `expected` and `actual` dictionaries, using the configured options. + * In case of a validation failure **and** if `shouldAssert` is `true`, a test failure occurs. + * + * @param expected The expected dictionary to compare. + * @param actual The actual dictionary to compare. + * @param keyPath A list of keys or array indexes representing the path to the current value being compared. + * @param nodeTree A tree of configuration objects used to control various validation settings. + * @param shouldAssert Indicates if an assertion error should be thrown if `expected` and `actual` are not equal. + * @return `true` if `expected` and `actual` are equal based on the settings in `nodeTree`, otherwise returns `false`. + */ + private fun validateJSON( + expected: JSONObject?, + actual: JSONObject?, + keyPath: List, + nodeTree: NodeConfig, + shouldAssert: Boolean = true + ): Boolean { + if (expected == null) { + return true + } + if (actual == null) { + if (shouldAssert) { + fail( + """ + Expected JSON is non-null but Actual JSON is null. + + Expected: $expected + Actual: $actual + + Key path: ${keyPathAsString(keyPath)} + """.trimIndent() + ) + } + return false + } + + if (nodeTree.collectionEqualCount.isActive) { + if (expected.length() != actual.length()) { + if (shouldAssert) { + fail( + """ + Expected JSON count does not match Actual JSON. + + Expected count: ${expected.length()} + Actual count: ${actual.length()} + + Expected: $expected + Actual: $actual + + Key path: ${keyPathAsString(keyPath)} + """.trimIndent() + ) + } + return false + } + } else if (expected.length() > actual.length()) { + if (shouldAssert) { + fail( + """ + Expected JSON has more elements than Actual JSON. + + Expected count: ${expected.length()} + Actual count: ${actual.length()} + + Expected: $expected + Actual: $actual + + Key path: ${keyPathAsString(keyPath)} + """.trimIndent() + ) + } + return false + } + + var validationResult = true + + for (key in expected.keys()) { + validationResult = validateJSON( + expected.get(key), + actual.opt(key), + keyPath + key, + nodeTree.getNextNode(key), + shouldAssert + ) && validationResult + } + return validationResult + } + + /** + * Validates the provided `actual` value against a specified `nodeTree` configuration. + * + * This method traverses a `NodeConfig` tree to validate the `actual` value according to the specified node configuration. + * It handles different types of values including dictionaries and arrays, and applies the relevant validation rules + * based on the configuration of each node in the tree. + * + * Note that this logic is meant to perform negative validation (for example, the absence of keys), and this means when `actual` nodes run out + * validation automatically passes. Positive validation should use `expected` + `validateJSON`. + * + * @param actual The value to be validated, either [JSONObject] or [JSONArray]. + * @param keyPath An array representing the current traversal path in the node tree. Starts as an empty array. + * @param nodeTree The root of the `NodeConfig` tree against which the validation is performed. + * @return A `Boolean` indicating whether the `actual` value is valid based on the `nodeTree` configuration. + */ + private fun validateActual( + actual: Any?, + keyPath: List = listOf(), + nodeTree: NodeConfig + ): ValidationResult { + return when (actual) { + // Handle dictionaries + is JSONObject -> validateActual( + actual = actual, + keyPath = keyPath, + nodeTree = nodeTree + ) + // Handle arrays + is JSONArray -> validateActual( + actual = actual, + keyPath = keyPath, + nodeTree = nodeTree + ) + else -> { + // Check SingleNode and Subtree options set for ElementCount for this specific node. + // If it hits this case, then the ElementCount assertion was set on a non-collection type element + // and should emit a test failure. + if (nodeTree.getSingleNodeOption(ElementCount)?.elementCount != null || nodeTree.getSubtreeNodeOption(ElementCount)?.elementCount != null) { + fail( + """ + Invalid ElementCount assertion on a non-collection element. + Remove ElementCount requirements from this key path in the test setup. + + Key path: ${keyPathAsString(keyPath)} + """.trimIndent() + ) + ValidationResult(false, 1) + } + ValidationResult(true, 1) + } + } + } + + /** + * Validates a [JSONArray]'s values against the provided node configuration tree. + * + * This method iterates through each element in the given [JSONArray] and performs validation + * based on the provided [NodeConfig]. + * + * @param actual The [JSONArray] to be validated. + * @param keyPath An array representing the current path in the node tree during the traversal. + * @param nodeTree The current node in the [NodeConfig] tree against which the [actual] values are validated. + * @return A [Boolean] indicating whether all elements in the [actual] array are valid according to the node tree configuration. + */ + private fun validateActual( + actual: JSONArray?, + keyPath: List, + nodeTree: NodeConfig + ): ValidationResult { + var validationResult = true + var singleNodeElementCount = 0 + var subtreeElementCount = 0 + + if (actual != null) { + for (index in 0 until actual.length()) { + // ElementCount check + val currentElement = actual.opt(index) + val result = validateActual(currentElement, keyPath + index, nodeTree.getNextNode(index)) + validationResult = result.isValid && validationResult + when (currentElement) { + is JSONObject, is JSONArray -> { + subtreeElementCount += result.elementCount + } + else -> { + if (NodeConfig.resolveOption(ElementCount, index, nodeTree).isActive) { + singleNodeElementCount += result.elementCount + } + } + } + } + } + + var totalElementCount = singleNodeElementCount + subtreeElementCount + validationResult = validateElementCount(SingleNode, keyPath, nodeTree, singleNodeElementCount) && validationResult + validationResult = validateElementCount(Subtree, keyPath, nodeTree, totalElementCount) && validationResult + + return ValidationResult(validationResult, totalElementCount) + } + + /** + * Validates a dictionary of [JSONObject] values against the provided node configuration tree. + * + * This method iterates through each key-value pair in the given dictionary and performs validation + * based on the provided `NodeConfig`. + * + * @param actual The dictionary of [JSONObject] values to be validated. + * @param keyPath An array representing the current path in the node tree during the traversal. + * @param nodeTree The current node in the `NodeConfig` tree against which the `actual` values are validated. + * @return A `Boolean` indicating whether all key-value pairs in the `actual` dictionary are valid according to the node tree configuration. + */ + private fun validateActual( + actual: JSONObject?, + keyPath: List, + nodeTree: NodeConfig + ): ValidationResult { + var validationResult = true + var singleNodeElementCount = 0 + var subtreeElementCount = 0 + + if (actual != null) { + for (key in actual.keys()) { + // KeyMustBeAbsent check + // Check for keys that must be absent in the current node + NodeConfig.resolveOption(KeyMustBeAbsent, key, nodeTree) + .takeIf { it.isActive } + ?.let { + fail( + """ + Actual JSON must not have key with name: $key + + Actual: $actual + + Key path: ${keyPathAsString(keyPath + listOf(key))} + """.trimIndent() + ) + validationResult = false + } + + // ElementCount check + val currentElement = actual.get(key) + val result = validateActual(currentElement, keyPath + key, nodeTree.getNextNode(key)) + validationResult = result.isValid && validationResult + when (currentElement) { + is JSONObject, is JSONArray -> { + subtreeElementCount += result.elementCount + } + else -> { + if (NodeConfig.resolveOption(ElementCount, key, nodeTree).isActive) { + singleNodeElementCount += result.elementCount + } + } + } + } + } + + var totalElementCount = singleNodeElementCount + subtreeElementCount + validationResult = validateElementCount(SingleNode, keyPath, nodeTree, singleNodeElementCount) && validationResult + validationResult = validateElementCount(Subtree, keyPath, nodeTree, totalElementCount) && validationResult + + return ValidationResult(validationResult, totalElementCount) + } + + /** + * Validates that the element count for a given scope is satisfied within the node configuration. + * Used to validate [ElementCount] assertions. + * + * @param scope The scope of the element count check, either SingleNode or Subtree. + * @param keyPath The current path in the JSON hierarchy traversal. Used for test failure message info. + * @param node The current node in the NodeConfig tree against which the element count is validated. + * @param elementCount The actual count of elements to be validated. + * @return `true` if the element count is satisfied, `false` otherwise. + */ + private fun validateElementCount(scope: NodeConfig.Scope, keyPath: List, node: NodeConfig, elementCount: Int): Boolean { + var validationResult = true + val config = when (scope) { + SingleNode -> node.getSingleNodeOption(ElementCount) + Subtree -> node.getSubtreeNodeOption(ElementCount) + } + // Check if the element count is satisfied + config?.takeIf { it.isActive && it.elementCount != null }?.also { + validationResult = it.elementCount == elementCount + assertTrue( + """ + The ${if (scope == NodeConfig.Scope.Subtree) "subtree" else "single node"} expected element count + is not equal to the actual number of elements. + + Expected count: ${it.elementCount} + Actual count: $elementCount + + Key path: ${keyPathAsString(keyPath)} + """.trimIndent(), + validationResult + ) + } + return validationResult + } + + /** + * Generates a tree structure from an array of path `String`s. + * + * This function processes each path in `paths`, extracts its individual components using `processPathComponents`, and + * constructs a nested dictionary structure. The constructed dictionary is then merged into the main tree. If the resulting tree + * is empty after processing all paths, this function returns `null`. + * + * @param pathOptions An array of path `String`s to be processed. Each path represents a nested structure to be transformed + * into a tree-like dictionary. + * @param treeDefaults Defaults used for tree configuration. + * @return A tree-like dictionary structure representing the nested structure of the provided paths. Returns `null` if the + * resulting tree is empty. + */ + private fun generateNodeTree( + pathOptions: List, + treeDefaults: List + ): NodeConfig { + // Create the first node using the incoming defaults + val subtreeOptions: MutableMap = mutableMapOf() + for (treeDefault in treeDefaults) { + val key = treeDefault.optionKey + subtreeOptions[key] = treeDefault.config + } + val rootNode = NodeConfig(name = null, subtreeOptions = subtreeOptions) + + for (pathConfig in pathOptions) { + rootNode.createOrUpdateNode(pathConfig) + } + + return rootNode + } + + /** + * Converts a key path represented by a list of JSON object keys and array indexes into a human-readable string format. + * + * The key path is used to trace the recursive traversal of a nested JSON structure. + * For instance, the key path for the value "Hello" in the JSON `{ "a": { "b": [ "World", "Hello" ] } }` + * would be ["a", "b", 1]. + * This method would convert it to the string "a.b[1]". + * + * Special considerations: + * 1. If a key in the JSON object contains a dot (.), it will be escaped with a backslash in the resulting string. + * 2. Empty keys in the JSON object will be represented as "" in the resulting string. + * + * @param keyPath A list of keys or array indexes representing the path to a value in a nested JSON structure. + * + * @return A human-readable string representation of the key path. + */ + private fun keyPathAsString(keyPath: List): String { + var result = "" + for (item in keyPath) { + when (item) { + is String -> { + if (result.isNotEmpty()) { + result += "." + } + result += when { + item.contains(".") -> item.replace(".", "\\.") + item.isEmpty() -> "\"\"" + else -> item + } + } + is Int -> result += "[$item]" + } + } + return result + } + + /** + * Converts the given object to its JSON representation. + * + * This method handles various types of input including null, strings representing JSON objects or arrays, + * maps, lists, and arrays. Null values within maps, lists, and arrays are replaced with `JSONObject.NULL`. + * All other inputs throw an exception. + * + * @param obj the object to be converted to JSON representation. Can be null, a JSON string, JSONObject, JSONArray, + * a map, a list, or an array. + * @return the JSON representation of the given object. Can be a `JSONObject`, `JSONArray`, or null if null was the input. + * @throws IllegalArgumentException if the input string is an invalid JSON string or if the input type is unsupported. + */ + @VisibleForTesting + fun getJSONRepresentation(obj: Any?): Any? { + return when (obj) { + null -> null + is String -> { + try { + JSONObject(obj) // Attempt to parse as JSONObject first. + } catch (e: JSONException) { + try { + JSONArray(obj) // Attempt to parse as JSONArray if JSONObject fails. + } catch (e: JSONException) { + throw IllegalArgumentException("Failed to convert to JSON representation: Invalid JSON string '$obj'") + } + } + } + is JSONObject, is JSONArray, is Map<*, *>, is List<*>, is Array<*> -> recursiveJSONRepresentation(obj) + else -> throw IllegalArgumentException("Failed to convert to JSON representation: $obj, with reason: Unsupported type ${obj.javaClass.kotlin}") + } + } + + /** + * Recursively converts the given object to its JSON representation. + * + * This method handles nested structures like maps, lists, and arrays, replacing null values with `JSONObject.NULL`. + * Basic types such as strings, numbers, and booleans are returned as-is. + * + * @param obj the object to be converted to JSON representation. Can be null, a `JSONObject`, a `JSONArray`, a map, + * a list, an array, or a basic type (string, number, boolean). + * @return the JSON representation of the given object. Can be a `JSONObject`, `JSONArray`, or a basic type. + * @throws IllegalArgumentException if the input map contains non-string keys or if the input type is unsupported. + */ + private fun recursiveJSONRepresentation(obj: Any?): Any? { + return when (obj) { + null -> JSONObject.NULL + is JSONObject, is JSONArray -> obj + is Map<*, *> -> { + try { + // Validate all strings are keys before trying to convert to JSON + if (obj.keys.all { it is String }) { + // Create a new map where null values are replaced with JSONObject.NULL + // and other values are recursively processed + val updatedMap = obj.mapValues { entry -> + if (entry.value == null) JSONObject.NULL else recursiveJSONRepresentation(entry.value) + } + JSONObject(updatedMap) + } else { + throw IllegalArgumentException("Failed to convert to JSON representation: Invalid JSON dictionary keys. Keys must be strings. Found: ${obj.keys}") + } + } catch (e: Exception) { + throw IllegalArgumentException("Failed to create JSONObject: $obj, with reason: ${e.message}") + } + } + is List<*> -> try { + // Recursively process each element in the list + JSONArray(obj.map { recursiveJSONRepresentation(it) }) + } catch (e: Exception) { + throw IllegalArgumentException("Failed to create JSONArray from List: $obj, with reason: ${e.message}") + } + is Array<*> -> try { + // Convert array to list and recursively process each element + JSONArray(obj.map { recursiveJSONRepresentation(it) }) + } catch (e: Exception) { + throw IllegalArgumentException("Failed to create JSONArray from Array: $obj, with reason: ${e.message}") + } + is String, is Number, is Boolean -> obj + else -> throw IllegalArgumentException("Failed to convert to JSON representation: $obj, with reason: Unsupported type ${obj.javaClass.kotlin}") + } + } + // endregion +} diff --git a/code/testutils/src/main/java/com/adobe/marketing/mobile/util/MockConnection.java b/code/testutils/src/main/java/com/adobe/marketing/mobile/util/MockConnection.java new file mode 100644 index 000000000..295c21205 --- /dev/null +++ b/code/testutils/src/main/java/com/adobe/marketing/mobile/util/MockConnection.java @@ -0,0 +1,96 @@ +/* + 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.HttpConnecting; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.Map; + +public class MockConnection implements HttpConnecting { + + public MockConnection(final int responseCode, final String responseBody, final String errorBody) { + this(responseCode, responseBody, errorBody, null); + } + + public MockConnection( + final int responseCode, + final String responseBody, + final String errorBody, + final Map headers + ) { + mockGetResponseCode = responseCode; + mockResponseBody = responseBody; + mockErrorBody = errorBody; + mockGetResponsePropertyValues = headers; + } + + public int getInputStreamCalledTimes = 0; + private String mockResponseBody; + + @Override + public InputStream getInputStream() { + getInputStreamCalledTimes += 1; + + if (mockResponseBody == null) { + return null; + } + + return new ByteArrayInputStream(mockResponseBody.getBytes()); + } + + public int getErrorStreamCalledTimes = 0; + private String mockErrorBody; + + @Override + public InputStream getErrorStream() { + getErrorStreamCalledTimes += 1; + + if (mockErrorBody == null) { + return null; + } + + return new ByteArrayInputStream(mockErrorBody.getBytes()); + } + + public int getResponseCodeCalledTimes = 0; + private int mockGetResponseCode = 0; + + @Override + public int getResponseCode() { + getResponseCodeCalledTimes += 1; + return mockGetResponseCode; + } + + @Override + public String getResponseMessage() { + return null; + } + + private Map mockGetResponsePropertyValues; + + @Override + public String getResponsePropertyValue(final String value) { + if (mockGetResponsePropertyValues == null) { + return null; + } + + return mockGetResponsePropertyValues.get(value); + } + + public int closeCalledTimes = 0; + + @Override + public void close() { + closeCalledTimes += 1; + } +} diff --git a/code/testutils/src/main/java/com/adobe/marketing/mobile/util/MockNetworkService.kt b/code/testutils/src/main/java/com/adobe/marketing/mobile/util/MockNetworkService.kt new file mode 100644 index 000000000..8ea7e2770 --- /dev/null +++ b/code/testutils/src/main/java/com/adobe/marketing/mobile/util/MockNetworkService.kt @@ -0,0 +1,328 @@ +/* + 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.services.HttpConnecting +import com.adobe.marketing.mobile.services.HttpMethod +import com.adobe.marketing.mobile.services.Log +import com.adobe.marketing.mobile.services.NetworkCallback +import com.adobe.marketing.mobile.services.NetworkRequest +import com.adobe.marketing.mobile.services.Networking +import com.adobe.marketing.mobile.services.TestableNetworkRequest +import java.io.ByteArrayInputStream +import java.io.InputStream +import java.nio.charset.StandardCharsets +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +/** + * [Networking] conforming network service test helper utility class used for tests that require mocked + * network requests and mocked responses. + */ +class MockNetworkService : Networking { + private val helper = NetworkRequestHelper() + /** + * Flag that indicates if the [connectAsync] method was called. + * Note that this property does not await and returns the status immediately. + */ + val connectAsyncCalled: Boolean + get() { + // Assumes that `NetworkRequestHelper.recordSentNetworkRequest` is always called by `connectAsync`. + // If this assumption changes, this flag logic needs to be updated. + return helper.networkRequests.isNotEmpty() + } + /** + * How many times the [connectAsync] method was called. + * Note that this property does not await and returns the value immediately. + */ + val connectAsyncCallCount: Int + get() { + // Assumes that `NetworkRequestHelper.recordSentNetworkRequest` is always called by `connectAsync`. + // If this assumption changes, this flag logic needs to be updated. + return helper.networkRequests.count() + } + + // Simulating the async network service + private val executorService: ExecutorService = Executors.newCachedThreadPool() + + companion object { + private const val LOG_SOURCE = "MockNetworkService" + private var delayedResponse = 0 + private var defaultResponse: HttpConnecting? = object : HttpConnecting { + override fun getInputStream(): InputStream { + return ByteArrayInputStream("".toByteArray()) + } + + override fun getErrorStream(): InputStream? { + return null + } + + override fun getResponseCode(): Int { + return 200 + } + + override fun getResponseMessage(): String { + return "" + } + + override fun getResponsePropertyValue(responsePropertyKey: String): String? { + return null + } + + override fun close() {} + } + } + + override fun connectAsync(networkRequest: NetworkRequest?, resultCallback: NetworkCallback?) { + val request = TestableNetworkRequest.from(networkRequest) + if (request == null) { + Log.error( + TestConstants.LOG_TAG, + LOG_SOURCE, + "Received null network request. Early exiting connectAsync method." + ) + return + } + Log.trace( + TestConstants.LOG_TAG, + LOG_SOURCE, + "Received connectUrlAsync to URL '${request.url}' and HttpMethod '${request.method.name}'." + ) + + helper.recordNetworkRequest(request) + + executorService.submit { + if (resultCallback != null) { + if (delayedResponse > 0) { + try { + Thread.sleep((delayedResponse * 1000).toLong()) + } catch (e: InterruptedException) { + e.printStackTrace() + } + } + // Since null responses are valid responses, only use the default response if no response + // has been set for this request. + val responses = helper.getResponsesFor(request) + val response = if (responses != null) responses.firstOrNull() else defaultResponse + resultCallback.call(response) + // Do countdown after notifying completion handler to avoid prematurely ungating awaits + // before required network logic finishes + helper.countDownExpected(request) + } + } + } + + /** + * Clears all test expectations and recorded network requests and responses. Resets the default + * response. + */ + fun reset() { + delayedResponse = 0 + helper.reset() + defaultResponse = object : HttpConnecting { + override fun getInputStream(): InputStream { + return ByteArrayInputStream("".toByteArray()) + } + + override fun getErrorStream(): InputStream? { + return null + } + + override fun getResponseCode(): Int { + return 200 + } + + override fun getResponseMessage(): String { + return "" + } + + override fun getResponsePropertyValue(responsePropertyKey: String): String? { + return null + } + + override fun close() {} + } + } + + /** + * Sets the provided delay for all network responses, until reset. + * @param delaySec The delay in seconds. + */ + fun enableNetworkResponseDelay(delaySec: Int) { + if (delaySec < 0) { + return + } + delayedResponse = delaySec + } + + /** + * Sets a mock network response for the provided network request. + * + * @param url The URL `String` of the [TestableNetworkRequest] for which the mock response is being set. + * @param method The [HttpMethod] of the [TestableNetworkRequest] for which the mock response is being set. + * @param responseConnection The [HttpConnecting] instance to set as a response. If `null` is provided, the [defaultResponse] is used. + */ + @JvmOverloads + fun setMockResponseFor( + url: String, + method: HttpMethod = HttpMethod.POST, + responseConnection: HttpConnecting? + ) { + helper.addResponseFor( + TestableNetworkRequest( + url, + method + ), + responseConnection + ) + } + + /** + * Sets the default network response for all requests. + * + * @param responseConnection The [HttpConnecting] instance to be set as the default response. + */ + fun setDefaultResponse(responseConnection: HttpConnecting?) { + defaultResponse = responseConnection + } + + /** + * Sets the expected number of times a network request should be sent. + * + * @param url The URL `String` of the [TestableNetworkRequest] for which the expectation is set. + * @param method The [HttpMethod] of the [TestableNetworkRequest] for which the expectation is set. + * @param expectedCount The number of times the request is expected to be sent. + */ + fun setExpectationForNetworkRequest( + url: String, + method: HttpMethod, + expectedCount: Int + ) { + helper.setExpectationFor( + TestableNetworkRequest( + url, + method + ), + expectedCount + ) + } + + /** + * Asserts that the correct number of network requests were sent based on previously set expectations. + * + * @throws InterruptedException If the current thread is interrupted while waiting. + * @see [setExpectationForNetworkRequest] + */ + @JvmOverloads + fun assertAllNetworkRequestExpectations( + ignoreUnexpectedRequests: Boolean = true, + waitForUnexpectedEvents: Boolean = true, + timeoutMillis: Int = TestConstants.Defaults.WAIT_NETWORK_REQUEST_TIMEOUT_MS + ) { + helper.assertAllNetworkRequestExpectations(ignoreUnexpectedRequests, waitForUnexpectedEvents, timeoutMillis) + } + + /** + * Returns all sent network requests (if any). + * If a timeout is specified (default is 2000 milliseconds), the function will wait for the specified time for the network requests to be recorded. + * If the timeout is set to 0, the function will immediately return the network requests without waiting. + */ + fun getAllNetworkRequests(timeoutMillis: Int = TestConstants.Defaults.WAIT_NETWORK_REQUEST_TIMEOUT_MS): List { + TestHelper.waitForThreads(timeoutMillis) + return helper.networkRequests + } + + /** + * Returns the network request(s) sent through the Core NetworkService, or an empty list if none was found. + * + * Use this method after calling [setExpectationForNetworkRequest] to wait for expected requests. + * + * @param url The URL `String` of the [NetworkRequest] to get. + * @param method The [HttpMethod] of the [NetworkRequest] to get. + * @param timeoutMillis The duration (in milliseconds) to wait for the expected network requests before + * timing out. Defaults to [TestConstants.Defaults.WAIT_NETWORK_REQUEST_TIMEOUT_MS]. + * + * @return A list of matching [TestableNetworkRequest]s. Returns an empty list if no matching requests were dispatched. + * + * @throws InterruptedException If the current thread is interrupted while waiting. + * + * @see setExpectationForNetworkRequest + */ + @Throws(InterruptedException::class) + @JvmOverloads + fun getNetworkRequestsWith( + url: String, + method: HttpMethod, + timeoutMillis: Int = TestConstants.Defaults.WAIT_NETWORK_REQUEST_TIMEOUT_MS + ): List { + return helper.getNetworkRequestsWith(url, method, timeoutMillis) + } + + /** + * Create a mock network response to be used when calling [setMockResponseFor]. + * @param responseString the network response string, returned by [HttpConnecting.getInputStream] + * @param code the HTTP status code, returned by [HttpConnecting.getResponseCode] + * @return an [HttpConnecting] object + * @see setMockResponseFor + */ + fun createMockNetworkResponse(responseString: String?, code: Int): HttpConnecting { + return createMockNetworkResponse(responseString, null, code, null, null) + } + + /** + * Create a mock network response to be used when calling [setMockResponseFor]. + * @param responseString the network response string, returned by [HttpConnecting.getInputStream] + * @param errorString the network error string, returned by [HttpConnecting.getErrorStream] + * @param code the HTTP status code, returned by [HttpConnecting.getResponseCode] + * @param responseMessage the network response message, returned by [HttpConnecting.getResponseMessage] + * @param propertyMap the network response header map, returned by [HttpConnecting.getResponsePropertyValue] + * @return an [HttpConnecting] object + * @see setMockResponseFor + */ + fun createMockNetworkResponse( + responseString: String?, + errorString: String?, + code: Int, + responseMessage: String?, + propertyMap: Map? + ): HttpConnecting { + return object : HttpConnecting { + override fun getInputStream(): InputStream? { + return if (responseString != null) { + ByteArrayInputStream(responseString.toByteArray(StandardCharsets.UTF_8)) + } else null + } + + override fun getErrorStream(): InputStream? { + return if (errorString != null) { + ByteArrayInputStream(errorString.toByteArray(StandardCharsets.UTF_8)) + } else null + } + + override fun getResponseCode(): Int { + return code + } + + override fun getResponseMessage(): String? { + return responseMessage + } + + override fun getResponsePropertyValue(responsePropertyKey: String): String? { + return if (propertyMap != null) { + propertyMap[responsePropertyKey] + } else null + } + + override fun close() {} + } + } +} diff --git a/code/testutils/src/main/java/com/adobe/marketing/mobile/util/MonitorExtension.java b/code/testutils/src/main/java/com/adobe/marketing/mobile/util/MonitorExtension.java new file mode 100644 index 000000000..41e96e31c --- /dev/null +++ b/code/testutils/src/main/java/com/adobe/marketing/mobile/util/MonitorExtension.java @@ -0,0 +1,255 @@ +/* + 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 androidx.annotation.NonNull; +import com.adobe.marketing.mobile.Event; +import com.adobe.marketing.mobile.EventSource; +import com.adobe.marketing.mobile.EventType; +import com.adobe.marketing.mobile.Extension; +import com.adobe.marketing.mobile.ExtensionApi; +import com.adobe.marketing.mobile.MobileCore; +import com.adobe.marketing.mobile.SharedStateResolution; +import com.adobe.marketing.mobile.SharedStateResult; +import com.adobe.marketing.mobile.services.Log; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * A third party extension class aiding for assertion against dispatched events, shared state + * and XDM shared state. + */ +public class MonitorExtension extends Extension { + + public static final Class EXTENSION = MonitorExtension.class; + private static final String LOG_SOURCE = "MonitorExtension"; + + private static final Map> receivedEvents = new HashMap<>(); + private static final Map expectedEvents = new HashMap<>(); + + protected MonitorExtension(ExtensionApi extensionApi) { + super(extensionApi); + } + + @NonNull @Override + protected String getName() { + return "MonitorExtension"; + } + + @Override + protected void onRegistered() { + super.onRegistered(); + getApi().registerEventListener(EventType.WILDCARD, EventSource.WILDCARD, this::wildcardProcessor); + } + + /** + * Unregister the Monitor Extension from the EventHub. + */ + public static void unregisterExtension() { + Event event = new Event.Builder( + "Unregister Monitor Extension Request", + TestConstants.EventType.MONITOR, + TestConstants.EventSource.UNREGISTER + ) + .build(); + MobileCore.dispatchEvent(event); + } + + /** + * Add an event to the list of expected events. + * @param type the type of the event. + * @param source the source of the event. + * @param count the number of events expected to be received. + */ + public static void setExpectedEvent(final String type, final String source, final int count) { + EventSpec eventSpec = new EventSpec(source, type); + expectedEvents.put(eventSpec, new ADBCountDownLatch(count)); + } + + public static Map getExpectedEvents() { + return expectedEvents; + } + + public static Map> getReceivedEvents() { + return receivedEvents; + } + + /** + * Resets the map of received and expected events. + */ + public static void reset() { + Log.trace(LOG_TAG, LOG_SOURCE, "Reset expected and received events."); + receivedEvents.clear(); + expectedEvents.clear(); + } + + /** + * Processor for all heard events. + * If the event type is of this Monitor Extension, then + * the action is performed per the event source. + * All other events are added to the map of received events. If the event is in the map + * of expected events, its latch is counted down. + * + * @param event current event to be processed + */ + public void wildcardProcessor(final Event event) { + if (TestConstants.EventType.MONITOR.equalsIgnoreCase(event.getType())) { + if (TestConstants.EventSource.SHARED_STATE_REQUEST.equalsIgnoreCase(event.getSource())) { + processSharedStateRequest(event); + } else if (TestConstants.EventSource.XDM_SHARED_STATE_REQUEST.equalsIgnoreCase(event.getSource())) { + processXDMSharedStateRequest(event); + } else if (TestConstants.EventSource.UNREGISTER.equalsIgnoreCase(event.getSource())) { + processUnregisterRequest(event); + } + + return; + } + + EventSpec eventSpec = new EventSpec(event.getSource(), event.getType()); + + Log.debug(LOG_TAG, LOG_SOURCE, "Received and processing event %s", eventSpec.toString()); + + if (!receivedEvents.containsKey(eventSpec)) { + receivedEvents.put(eventSpec, new ArrayList()); + } + + receivedEvents.get(eventSpec).add(event); + + if (expectedEvents.containsKey(eventSpec)) { + expectedEvents.get(eventSpec).countDown(); + } + } + + /** + * Processor which retrieves and dispatches the shared state for the state owner specified + * in the request. + * @param event current event to be processed + */ + private void processSharedStateRequest(final Event event) { + Map eventData = event.getEventData(); + + if (eventData == null) { + return; + } + + String stateOwner = DataReader.optString(eventData, TestConstants.EventDataKey.STATE_OWNER, null); + if (stateOwner == null) { + return; + } + + SharedStateResult sharedStateResult = getApi() + .getSharedState(stateOwner, event, false, SharedStateResolution.ANY); + Event responseEvent = new Event.Builder( + "Get Shared State Response", + TestConstants.EventType.MONITOR, + TestConstants.EventSource.SHARED_STATE_RESPONSE + ) + .setEventData(sharedStateResult != null ? sharedStateResult.getValue() : new HashMap<>()) + .inResponseToEvent(event) + .build(); + MobileCore.dispatchEvent(responseEvent); + } + + /** + * Processor which retrieves and dispatches the XDM shared state for the state owner specified + * in the request. + * @param event the event to be processed + */ + private void processXDMSharedStateRequest(final Event event) { + final Map eventData = event.getEventData(); + + if (eventData == null) { + return; + } + + final String stateOwner = DataReader.optString(eventData, TestConstants.EventDataKey.STATE_OWNER, null); + + if (stateOwner == null) { + return; + } + + final SharedStateResult sharedStateResult = getApi() + .getXDMSharedState(stateOwner, event, false, SharedStateResolution.LAST_SET); + + Event responseEvent = new Event.Builder( + "Get Shared State Response", + TestConstants.EventType.MONITOR, + TestConstants.EventSource.XDM_SHARED_STATE_RESPONSE + ) + .setEventData(sharedStateResult == null ? null : sharedStateResult.getValue()) + .inResponseToEvent(event) + .build(); + + MobileCore.dispatchEvent(responseEvent); + } + + /** + * Processor which unregisters this extension. + * @param event current event to be processed + */ + private void processUnregisterRequest(final Event event) { + Log.debug(LOG_TAG, LOG_SOURCE, "Unregistering the Monitor Extension."); + getApi().unregisterExtension(); + } + + /** + * Class defining {@link Event} specifications, contains Event's source and type. + */ + public static class EventSpec { + + public final String source; + public final String type; + + public EventSpec(final String source, final String type) { + if (source == null || source.isEmpty()) { + throw new IllegalArgumentException("Event Source cannot be null or empty."); + } + + if (type == null || type.isEmpty()) { + throw new IllegalArgumentException("Event Type cannot be null or empty."); + } + + // Normalize strings + this.source = source.toLowerCase(); + this.type = type.toLowerCase(); + } + + @NonNull @Override + public String toString() { + return "type '" + type + "' and source '" + source + "'"; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (o == null || getClass() != o.getClass()) { + return false; + } + + EventSpec eventSpec = (EventSpec) o; + return Objects.equals(source, eventSpec.source) && Objects.equals(type, eventSpec.type); + } + + @Override + public int hashCode() { + return Objects.hash(source, type); + } + } +} diff --git a/code/testutils/src/main/java/com/adobe/marketing/mobile/util/NetworkRequestHelper.kt b/code/testutils/src/main/java/com/adobe/marketing/mobile/util/NetworkRequestHelper.kt new file mode 100644 index 000000000..ae5a08c12 --- /dev/null +++ b/code/testutils/src/main/java/com/adobe/marketing/mobile/util/NetworkRequestHelper.kt @@ -0,0 +1,268 @@ +/* + 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 com.adobe.marketing.mobile.services.HttpConnecting +import com.adobe.marketing.mobile.services.HttpMethod +import com.adobe.marketing.mobile.services.Log +import com.adobe.marketing.mobile.services.NetworkRequest +import com.adobe.marketing.mobile.services.TestableNetworkRequest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import java.util.concurrent.TimeUnit + +/** + * Provides shared utilities and logic for implementations of `Networking` classes used for testing. + * + * @see [MockNetworkService] + * @see [RealNetworkService] + */ +class NetworkRequestHelper { + private val _networkRequests: MutableList = mutableListOf() + // Read-only access to network requests + val networkRequests: List + get() = _networkRequests + + private val _networkResponses: MutableMap> = HashMap() + // Read-only access to network responses + val networkResponses: Map> + get() = _networkResponses.mapValues { it.value.toList() } + + private val expectedNetworkRequests: MutableMap = HashMap() + + companion object { + private const val LOG_SOURCE = "NetworkRequestHelper" + } + + /** + * Records a sent network request. + * + * @param request The [TestableNetworkRequest] that is to be recorded. + */ + fun recordNetworkRequest(request: TestableNetworkRequest) { + Log.trace( + TestConstants.LOG_TAG, + LOG_SOURCE, + "Recording network request with URL ${request.url} and HTTPMethod ${request.method}" + ) + + _networkRequests.add(request) + } + + /** + * Resets the helper state by clearing all test expectations and recorded network requests and responses. + */ + fun reset() { + Log.trace( + TestConstants.LOG_TAG, + LOG_SOURCE, + "Reset network request expectations and recorded network requests and responses." + ) + _networkRequests.clear() + _networkResponses.clear() + expectedNetworkRequests.clear() + } + + /** + * Decrements the expectation count for a given network request. + * + * @param request The [TestableNetworkRequest] for which the expectation count should be decremented. + */ + fun countDownExpected(request: TestableNetworkRequest) { + expectedNetworkRequests[request]?.countDown() + } + + /** + * Asserts on the expectation for the given network request, validating that all expected responses are received + * within the provided timeout duration and that expected count is not exceeded. If no expectation is + * set for the request, a configurable default wait time is applied. + * + * @param request The [NetworkRequest] for which the expectation should be asserted. + * @param timeoutMillis The maximum duration (in milliseconds) to wait for the expected responses before timing out. + * @param waitForUnexpectedEvents If `true`, a default wait time will be used when no expectation is set for the request. + * Defaults to `true`. + * + * @throws InterruptedException If the current thread is interrupted while waiting. + */ + @Throws(InterruptedException::class) + fun awaitRequest(request: TestableNetworkRequest, timeoutMillis: Int, waitForUnexpectedEvents: Boolean = true) { + val expectation = expectedNetworkRequests[request] + if (expectation != null) { + val awaitResult = expectation.await(timeoutMillis.toLong(), TimeUnit.MILLISECONDS) + // Verify that the expectation passes within the given timeout + assertTrue( + """ + Time out waiting for network request with URL '${request.url}' and HTTP method + '${request.method.name}'. Received (${expectation.currentCount}/${expectation.initialCount}) expected requests. + """.trimIndent(), + awaitResult + ) + // Validate that the actual count does not exceed the expected count + assertEquals( + """ + Expected only ${expectation.initialCount} network request(s) for URL ${request.url} and + HTTP method ${request.method.name}, but received ${expectation.currentCount}. + """.trimIndent(), + expectation.initialCount, + expectation.currentCount + ) + } + // Default wait time for network request with no previously set expectation + else { + if (waitForUnexpectedEvents) { + TestHelper.waitForThreads(timeoutMillis) + } + } + } + + /** + * Immediately returns all network requests that match the provided network request. Does **not** + * await. + * + * The matching logic relies on [TestableNetworkRequest.equals]. + * + * @param request The [TestableNetworkRequest] for which to get matching requests. + * + * @return A list of [TestableNetworkRequest]s that match the provided [request]. If no matches are found, an empty list is returned. + */ + fun getRequestsMatching(request: TestableNetworkRequest): List { + return _networkRequests.filter { it == request } + } + + /** + * Adds a network response for the provided network request. If a response already exists, adds + * the given response to the end of the list. + * + * @param request The [TestableNetworkRequest] for which the response will be set. + * @param responseConnection The [HttpConnecting] to add as a response. + */ + fun addResponseFor( + request: TestableNetworkRequest, + responseConnection: HttpConnecting? + ) { + if (_networkResponses[request] != null) { + _networkResponses[request]?.add(responseConnection) + } else { + // If there's no response for this request yet, start a new list with the first response + _networkResponses[request] = mutableListOf(responseConnection) + } + } + + /** + * Removes all network responses for the provided network request. + * + * @param request The [TestableNetworkRequest] for which all responses will be removed. + */ + fun removeResponsesFor(request: TestableNetworkRequest) { + _networkResponses.remove(request) + } + + /** + * Returns the network responses for the given network request. + * + * @param request The [TestableNetworkRequest] for which the associated responses should be returned. + * @return The list of [HttpConnecting] responses for the given request or `null` if not found. + * @see [TestableNetworkRequest.equals] for the logic used to match network requests. + */ + fun getResponsesFor(request: TestableNetworkRequest): List? { + return _networkResponses[request] + } + + /** + * Sets the expected number of times a network request should be sent. If there is already an existing expectation + * for the same request, it is replaced with the new value. + * + * @param request The [TestableNetworkRequest] to set the expectation for. + * @param count The expected number of times the request should be sent. + * @see [assertAllNetworkRequestExpectations] for checking all expectations. + */ + fun setExpectationFor(request: TestableNetworkRequest, count: Int) { + expectedNetworkRequests[request] = ADBCountDownLatch(count) + } + + /** + * Asserts that the correct number of network requests have been sent based on previously set expectations. + * It waits for expected requests to complete and optionally checks for unexpected ones. + * + * @param ignoreUnexpectedRequests If `true`, skips validation of unexpected requests. Defaults to `true`. + * @param waitForUnexpectedRequests If `true`, waits for unexpected requests to occur within the given timeout. Defaults to `true`. + * @param timeoutMillis The maximum time to wait (in milliseconds) for expected requests to complete. Defaults to [TestConstants.Defaults.WAIT_NETWORK_REQUEST_TIMEOUT_MS]. + * + * @throws InterruptedException If the current thread is interrupted while waiting. + * @see [setExpectationFor] to set expectations for specific network requests. + */ + @Throws(InterruptedException::class) + fun assertAllNetworkRequestExpectations( + ignoreUnexpectedRequests: Boolean = true, + waitForUnexpectedRequests: Boolean = true, + timeoutMillis: Int = TestConstants.Defaults.WAIT_NETWORK_REQUEST_TIMEOUT_MS + ) { + // Allow for some extra time for threads to finish before asserts + TestHelper.waitForThreads(2000) + // Validate expected events + for (expectedRequest in expectedNetworkRequests.keys) { + awaitRequest(expectedRequest, timeoutMillis, false) + } + // Validate unexpected requests if required + if (ignoreUnexpectedRequests) { + return + } + if (waitForUnexpectedRequests) { + TestHelper.waitForThreads(timeoutMillis) + } + assertNoUnexpectedRequests() + } + + /** + * Asserts that there are no unexpected network requests. + */ + private fun assertNoUnexpectedRequests() { + // Group unexpected network requests (those not in expected keys) by TestableNetworkRequest equality + val groupedRequests = _networkRequests + .filter { it !in expectedNetworkRequests.keys } + .groupBy { it } + + val failureDetails = groupedRequests.entries.joinToString(separator = "\n") { (request, group) -> + "(URL: ${request.url}, HTTPMethod: ${request.method.name}, Count: ${group.size})" + } + + // If there are any unexpected requests, fail with a message + if (failureDetails.isNotEmpty()) { + fail("Received unexpected network request(s): \n$failureDetails") + } + } + + /** + * Fetches the network request(s) matching the provided [url] and [method], returning an empty list if none were found. + * To wait for expected requests, this method should be used after calling [setExpectationFor]. + * + * @param url The URL string of the [NetworkRequest] to match. + * @param method The HTTP method of the [NetworkRequest] to match. + * @param timeoutMillis The time in milliseconds to wait for the expected network requests. Defaults to [TestConstants.Defaults.WAIT_NETWORK_REQUEST_TIMEOUT_MS]. + * + * @return A list of [NetworkRequest]s that match the provided [url] and [method]. If no matching requests are found, + * an empty list is returned. + * + * @throws InterruptedException If the current thread is interrupted while waiting. + */ + @Throws(InterruptedException::class) + fun getNetworkRequestsWith( + url: String, + method: HttpMethod, + timeoutMillis: Int = TestConstants.Defaults.WAIT_NETWORK_REQUEST_TIMEOUT_MS + ): List { + val testableRequest = TestableNetworkRequest(url, method) + awaitRequest(testableRequest, timeoutMillis) + return getRequestsMatching(testableRequest) + } +} diff --git a/code/testutils/src/main/java/com/adobe/marketing/mobile/util/NodeConfig.kt b/code/testutils/src/main/java/com/adobe/marketing/mobile/util/NodeConfig.kt new file mode 100644 index 000000000..1fc795d3f --- /dev/null +++ b/code/testutils/src/main/java/com/adobe/marketing/mobile/util/NodeConfig.kt @@ -0,0 +1,1131 @@ +/* + 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.junit.Assert.fail +import java.util.Objects + +/** + * An interface that defines a multi-path configuration. + * + * This interface provides the necessary properties to configure multiple paths + * within a node configuration context. It is designed to be used where multiple + * paths need to be specified along with associated configuration options. + */ +interface MultiPathConfig { + /** + * A Boolean value indicating whether the configuration is active. + */ + val config: NodeConfig.Config + + /** + * A `NodeConfig.Scope` value defining the scope of the configuration, such as whether it is applied to a single node or a subtree. + */ + val scope: NodeConfig.Scope + + /** + * An array of optional strings representing the paths to be configured. + * Each string in the array represents a distinct path. `null` indicates the top-level object. + */ + val paths: List + + /** + * A `NodeConfig.OptionKey` value that specifies the type of option applied to the paths. + */ + val optionKey: NodeConfig.OptionKey +} + +/** + * A data class representing the configuration for a single path. + * + * This data class is used to define the configuration details for a specific path within + * a node configuration context. It encapsulates the path's specific options and settings. + */ +data class PathConfig( + /** + * A Boolean value indicating whether the configuration is active. + */ + var config: NodeConfig.Config, + + /** + * A `NodeConfig.Scope` value defining the scope of the configuration, such as whether it is applied to a single node or a subtree. + */ + var scope: NodeConfig.Scope, + + /** + * An optional String representing the path to be configured. `null` indicates the top-level object. + */ + var path: String?, + + /** + * A `NodeConfig.OptionKey` value that specifies the type of option applied to the path. + */ + var optionKey: NodeConfig.OptionKey +) + +/** + * Validation option which specifies: Array elements from `expected` may match elements from `actual` regardless of index position. + * When combining any position option indexes and standard indexes, standard indexes are validated first. + */ +data class AnyOrderMatch( + override val config: NodeConfig.Config = NodeConfig.Config(isActive = true), + override val scope: NodeConfig.Scope = NodeConfig.Scope.SingleNode, + override val paths: List = listOf(null), + override val optionKey: NodeConfig.OptionKey = NodeConfig.OptionKey.AnyOrderMatch +) : MultiPathConfig { + companion object { + private val defaultPaths = listOf(null) + private val defaultScope = NodeConfig.Scope.SingleNode + } + + /** + * Initializes a new instance with the specified parameters. + * + * @param isActive Boolean value indicating whether the configuration is active. + * @param scope The scope of configuration, defaulting to single node. + * @param paths A list of optional path strings. + */ + constructor(isActive: Boolean = true, scope: NodeConfig.Scope = NodeConfig.Scope.SingleNode, paths: List = defaultPaths) : + this(NodeConfig.Config(isActive = isActive), scope, paths) + + // Secondary constructor permutations are explicitly defined for Java compatibility + constructor(isActive: Boolean, paths: List) : + this(isActive, defaultScope, paths) + + constructor(scope: NodeConfig.Scope, paths: List) : + this(true, scope, paths) + + constructor(isActive: Boolean, scope: NodeConfig.Scope) : + this(isActive, scope, defaultPaths) + + constructor(isActive: Boolean) : + this(isActive, defaultPaths) + + constructor(scope: NodeConfig.Scope) : + this(scope, defaultPaths) + + constructor(paths: List) : + this(defaultScope, paths) + + // Variadic initializers rely on their List<*> constructor counterparts + /** + * Variadic initializer allowing multiple string paths. + * + * @param isActive Boolean value indicating whether the configuration is active. + * @param scope The scope of configuration, defaulting to single node. + * @param paths Vararg of optional path strings. + */ + constructor(isActive: Boolean = true, scope: NodeConfig.Scope = NodeConfig.Scope.SingleNode, vararg paths: String?) : + this(isActive, scope, if (paths.isEmpty()) defaultPaths else paths.toList()) + + constructor(isActive: Boolean, vararg paths: String?) : + this(isActive, if (paths.isEmpty()) defaultPaths else paths.toList()) + + constructor(scope: NodeConfig.Scope, vararg paths: String?) : + this(scope, if (paths.isEmpty()) defaultPaths else paths.toList()) + + constructor(vararg paths: String?) : + this(if (paths.isEmpty()) defaultPaths else paths.toList()) +} + +/** + * Validation option which specifies: Collections (objects and/or arrays) must have the same number of elements. + */ +data class CollectionEqualCount( + override val config: NodeConfig.Config = NodeConfig.Config(isActive = true), + override val scope: NodeConfig.Scope = NodeConfig.Scope.SingleNode, + override val paths: List = listOf(null), + override val optionKey: NodeConfig.OptionKey = NodeConfig.OptionKey.CollectionEqualCount, +) : MultiPathConfig { + companion object { + private val defaultPaths = listOf(null) + private val defaultScope = NodeConfig.Scope.SingleNode + } + + /** + * Initializes a new instance with the specified parameters. + * + * @param isActive Boolean value indicating whether the configuration is active. + * @param scope The scope of configuration, defaulting to single node. + * @param paths A list of optional path strings. + */ + constructor(isActive: Boolean = true, scope: NodeConfig.Scope = NodeConfig.Scope.SingleNode, paths: List = defaultPaths) : + this(NodeConfig.Config(isActive = isActive), scope, paths) + + // Secondary constructor permutations are explicitly defined for Java compatibility + constructor(isActive: Boolean, paths: List) : + this(isActive, defaultScope, paths) + + constructor(scope: NodeConfig.Scope, paths: List) : + this(true, scope, paths) + + constructor(isActive: Boolean, scope: NodeConfig.Scope) : + this(isActive, scope, defaultPaths) + + constructor(isActive: Boolean) : + this(isActive, defaultPaths) + + constructor(scope: NodeConfig.Scope) : + this(scope, defaultPaths) + + constructor(paths: List) : + this(defaultScope, paths) + + // Variadic initializers rely on their List<*> constructor counterparts + /** + * Variadic initializer allowing multiple string paths. + * + * @param isActive Boolean value indicating whether the configuration is active. + * @param scope The scope of configuration, defaulting to single node. + * @param paths Vararg of optional path strings. + */ + constructor(isActive: Boolean = true, scope: NodeConfig.Scope = NodeConfig.Scope.SingleNode, vararg paths: String?) : + this(isActive, scope, if (paths.isEmpty()) defaultPaths else paths.toList()) + + constructor(isActive: Boolean, vararg paths: String?) : + this(isActive, if (paths.isEmpty()) defaultPaths else paths.toList()) + + constructor(scope: NodeConfig.Scope, vararg paths: String?) : + this(scope, if (paths.isEmpty()) defaultPaths else paths.toList()) + + constructor(vararg paths: String?) : + this(if (paths.isEmpty()) defaultPaths else paths.toList()) +} + +/** + * Validation option which specifies: The given number of elements (dictionary keys and array elements) + * must be present. + */ +data class ElementCount( + override val config: NodeConfig.Config = NodeConfig.Config(isActive = true), + override val scope: NodeConfig.Scope = NodeConfig.Scope.SingleNode, + override val paths: List = listOf(null), + override val optionKey: NodeConfig.OptionKey = NodeConfig.OptionKey.ElementCount, +) : MultiPathConfig { + companion object { + private val defaultPaths = listOf(null) + private val defaultScope = NodeConfig.Scope.SingleNode + } + + /** + * Initializes a new instance with the specified parameters. + * + * @param requiredCount The number of elements required. + * @param isActive Boolean value indicating whether the configuration is active. + * @param scope The scope of configuration, defaulting to single node. + * @param paths A list of optional path strings. + */ + constructor(requiredCount: Int?, isActive: Boolean = true, scope: NodeConfig.Scope = NodeConfig.Scope.SingleNode, paths: List = defaultPaths) : + this(NodeConfig.Config(isActive = isActive, elementCount = requiredCount), scope, paths) + + // Secondary constructor permutations are explicitly defined for Java compatibility + constructor(requiredCount: Int?, isActive: Boolean, paths: List) : + this(requiredCount, isActive, defaultScope, paths) + + constructor(requiredCount: Int?, scope: NodeConfig.Scope, paths: List) : + this(requiredCount, true, scope, paths) + + constructor(requiredCount: Int?, isActive: Boolean, scope: NodeConfig.Scope) : + this(requiredCount, isActive, scope, defaultPaths) + + constructor(requiredCount: Int?, isActive: Boolean) : + this(requiredCount, isActive, defaultPaths) + + constructor(requiredCount: Int?, scope: NodeConfig.Scope) : + this(requiredCount, scope, defaultPaths) + + constructor(requiredCount: Int?, paths: List) : + this(requiredCount, defaultScope, paths) + + // Variadic initializers rely on their List<*> constructor counterparts + /** + * Variadic initializer allowing multiple string paths. + * + * @param requiredCount The number of elements required. + * @param isActive Boolean value indicating whether the configuration is active. + * @param scope The scope of configuration, defaulting to single node. + * @param paths Vararg of optional path strings. + */ + constructor(requiredCount: Int?, isActive: Boolean = true, scope: NodeConfig.Scope = NodeConfig.Scope.SingleNode, vararg paths: String?) : + this(requiredCount, isActive, scope, if (paths.isEmpty()) defaultPaths else paths.toList()) + + constructor(requiredCount: Int?, isActive: Boolean, vararg paths: String?) : + this(requiredCount, isActive, if (paths.isEmpty()) defaultPaths else paths.toList()) + + constructor(requiredCount: Int?, scope: NodeConfig.Scope, vararg paths: String?) : + this(requiredCount, scope, if (paths.isEmpty()) defaultPaths else paths.toList()) + + constructor(requiredCount: Int?, vararg paths: String?) : + this(requiredCount, if (paths.isEmpty()) defaultPaths else paths.toList()) +} + +/** + * Validation option which specifies: `actual` must not have the key name specified. + */ +data class KeyMustBeAbsent( + override val config: NodeConfig.Config = NodeConfig.Config(isActive = true), + override val scope: NodeConfig.Scope = NodeConfig.Scope.SingleNode, + override val paths: List = listOf(null), + override val optionKey: NodeConfig.OptionKey = NodeConfig.OptionKey.KeyMustBeAbsent, +) : MultiPathConfig { + companion object { + private val defaultPaths = listOf(null) + private val defaultScope = NodeConfig.Scope.SingleNode + } + + /** + * Initializes a new instance with the specified parameters. + * + * @param isActive Boolean value indicating whether the configuration is active. + * @param scope The scope of configuration, defaulting to single node. + * @param paths A list of optional path strings. + */ + constructor(isActive: Boolean = true, scope: NodeConfig.Scope = NodeConfig.Scope.SingleNode, paths: List = defaultPaths) : + this(NodeConfig.Config(isActive = isActive), scope, paths) + + // Secondary constructor permutations are explicitly defined for Java compatibility + constructor(isActive: Boolean, paths: List) : + this(isActive, defaultScope, paths) + + constructor(scope: NodeConfig.Scope, paths: List) : + this(true, scope, paths) + + constructor(isActive: Boolean, scope: NodeConfig.Scope) : + this(isActive, scope, defaultPaths) + + constructor(isActive: Boolean) : + this(isActive, defaultPaths) + + constructor(scope: NodeConfig.Scope) : + this(scope, defaultPaths) + + constructor(paths: List) : + this(defaultScope, paths) + + // Variadic initializers rely on their List<*> constructor counterparts + /** + * Variadic initializer allowing multiple string paths. + * + * @param isActive Boolean value indicating whether the configuration is active. + * @param scope The scope of configuration, defaulting to single node. + * @param paths Vararg of optional path strings. + */ + constructor(isActive: Boolean = true, scope: NodeConfig.Scope = NodeConfig.Scope.SingleNode, vararg paths: String?) : + this(isActive, scope, if (paths.isEmpty()) defaultPaths else paths.toList()) + + constructor(isActive: Boolean, vararg paths: String?) : + this(isActive, if (paths.isEmpty()) defaultPaths else paths.toList()) + + constructor(scope: NodeConfig.Scope, vararg paths: String?) : + this(scope, if (paths.isEmpty()) defaultPaths else paths.toList()) + + constructor(vararg paths: String?) : + this(if (paths.isEmpty()) defaultPaths else paths.toList()) +} + +/** + * Validation option which specifies: values must have the same type but the literal values must not be equal. + */ +data class ValueNotEqual( + override val config: NodeConfig.Config = NodeConfig.Config(isActive = true), + override val scope: NodeConfig.Scope = NodeConfig.Scope.SingleNode, + override val paths: List = listOf(null), + override val optionKey: NodeConfig.OptionKey = NodeConfig.OptionKey.ValueNotEqual, +) : MultiPathConfig { + companion object { + private val defaultPaths = listOf(null) + private val defaultScope = NodeConfig.Scope.SingleNode + } + + /** + * Initializes a new instance with the specified parameters. + * + * @param isActive Boolean value indicating whether the configuration is active. + * @param scope The scope of configuration, defaulting to single node. + * @param paths A list of optional path strings. + */ + constructor(isActive: Boolean = true, scope: NodeConfig.Scope = NodeConfig.Scope.SingleNode, paths: List = defaultPaths) : + this(NodeConfig.Config(isActive = isActive), scope, paths) + + // Secondary constructor permutations are explicitly defined for Java compatibility + constructor(isActive: Boolean, paths: List) : + this(isActive, defaultScope, paths) + + constructor(scope: NodeConfig.Scope, paths: List) : + this(true, scope, paths) + + constructor(isActive: Boolean, scope: NodeConfig.Scope) : + this(isActive, scope, defaultPaths) + + constructor(isActive: Boolean) : + this(isActive, defaultPaths) + + constructor(scope: NodeConfig.Scope) : + this(scope, defaultPaths) + + constructor(paths: List) : + this(defaultScope, paths) + + // Variadic initializers rely on their List<*> constructor counterparts + /** + * Variadic initializer allowing multiple string paths. + * + * @param isActive Boolean value indicating whether the configuration is active. + * @param scope The scope of configuration, defaulting to single node. + * @param paths Vararg of optional path strings. + */ + constructor(isActive: Boolean = true, scope: NodeConfig.Scope = NodeConfig.Scope.SingleNode, vararg paths: String?) : + this(isActive, scope, if (paths.isEmpty()) defaultPaths else paths.toList()) + + constructor(isActive: Boolean, vararg paths: String?) : + this(isActive, if (paths.isEmpty()) defaultPaths else paths.toList()) + + constructor(scope: NodeConfig.Scope, vararg paths: String?) : + this(scope, if (paths.isEmpty()) defaultPaths else paths.toList()) + + constructor(vararg paths: String?) : + this(if (paths.isEmpty()) defaultPaths else paths.toList()) +} + +/** + * Validation option which specifies that values must have the same type and literal value. + * This class applies to specified paths within a data structure, ensuring that values at these paths + * are exactly the same both in type and value. + * + * @property paths List of optional string paths indicating where the exact match validation is applied. + * @property optionKey Constant from NodeConfig.OptionKey indicating the specific validation option for exact matches. + * @property config Configuration details indicating whether this validation is active. + * @property scope Scope of the validation, indicating the extent of the data structure this rule applies to. + */ +data class ValueExactMatch( + override val config: NodeConfig.Config = NodeConfig.Config(isActive = true), + override val scope: NodeConfig.Scope = NodeConfig.Scope.SingleNode, + override val paths: List = listOf(null), + override val optionKey: NodeConfig.OptionKey = NodeConfig.OptionKey.PrimitiveExactMatch, +) : MultiPathConfig { + companion object { + private val defaultPaths = listOf(null) + private val defaultScope = NodeConfig.Scope.SingleNode + } + + /** + * Initializes a new instance with the specified parameters. + * + * @param scope The scope of configuration, defaulting to single node. + * @param paths A list of optional path strings. + */ + constructor(scope: NodeConfig.Scope = NodeConfig.Scope.SingleNode, paths: List = defaultPaths) : + this(NodeConfig.Config(isActive = true), scope, paths) + + // Secondary constructor permutations are explicitly defined for Java compatibility + constructor(scope: NodeConfig.Scope) : + this(scope, defaultPaths) + + constructor(paths: List) : + this(defaultScope, paths) + + // Variadic initializers rely on their List<*> constructor counterparts + /** + * Variadic initializer allowing multiple string paths. + * + * @param scope The scope of configuration, defaulting to single node. + * @param paths Vararg of optional path strings. + */ + constructor(scope: NodeConfig.Scope = NodeConfig.Scope.SingleNode, vararg paths: String?) : + this(scope, if (paths.isEmpty()) defaultPaths else paths.toList()) + + constructor(vararg paths: String?) : + this(if (paths.isEmpty()) defaultPaths else paths.toList()) +} + +/** + * Validation option which specifies: values must have the same type but their literal values can be different. + */ +data class ValueTypeMatch( + override val config: NodeConfig.Config = NodeConfig.Config(isActive = false), + override val scope: NodeConfig.Scope = NodeConfig.Scope.SingleNode, + override val paths: List = listOf(null), + override val optionKey: NodeConfig.OptionKey = NodeConfig.OptionKey.PrimitiveExactMatch, +) : MultiPathConfig { + companion object { + private val defaultPaths = listOf(null) + private val defaultScope = NodeConfig.Scope.SingleNode + } + + /** + * Initializes a new instance with the specified parameters. + * + * @param scope The scope of configuration, defaulting to single node. + * @param paths A list of optional path strings. + */ + constructor(scope: NodeConfig.Scope = NodeConfig.Scope.SingleNode, paths: List = defaultPaths) : + this(NodeConfig.Config(isActive = false), scope, paths) + + // Secondary constructor permutations are explicitly defined for Java compatibility + constructor(scope: NodeConfig.Scope) : + this(scope, defaultPaths) + + constructor(paths: List) : + this(defaultScope, paths) + + // Variadic initializers rely on their List<*> constructor counterparts + /** + * Variadic initializer allowing multiple string paths. + * + * @param scope The scope of configuration, defaulting to single node. + * @param paths Vararg of optional path strings. + */ + constructor(scope: NodeConfig.Scope = NodeConfig.Scope.SingleNode, vararg paths: String?) : + this(scope, if (paths.isEmpty()) defaultPaths else paths.toList()) + + constructor(vararg paths: String?) : + this(if (paths.isEmpty()) defaultPaths else paths.toList()) +} + +/** + * A class representing the configuration for a node in a tree structure. + * + * `NodeConfig` provides a way to set configuration options for nodes in a hierarchical tree structure. + * It supports different types of configuration options, including options that apply to individual nodes + * or to entire subtrees. + */ +class NodeConfig { + /** + * Represents the scope of the configuration; that is, to which nodes the configuration applies. + */ + enum class Scope(val value: String) { + SingleNode("SingleNode"), + Subtree("Subtree") + } + + /** + * Defines the types of configuration options available for nodes. + */ + enum class OptionKey(val value: String) { + AnyOrderMatch("AnyOrderMatch"), + CollectionEqualCount("CollectionEqualCount"), + ElementCount("ElementCount"), + KeyMustBeAbsent("KeyMustBeAbsent"), + PrimitiveExactMatch("PrimitiveExactMatch"), + ValueNotEqual("ValueNotEqual") + } + + /** + * Represents the configuration details for a comparison option + */ + data class Config(val isActive: Boolean, val elementCount: Int? = null) { + fun deepCopy(): Config { + return Config(isActive, elementCount) + } + } + + private data class PathComponent( + var name: String?, + var isAnyOrder: Boolean, + var isArray: Boolean, + var isWildcard: Boolean + ) + + /** + * A string representing the name of the node. `null` refers to the top level object + */ + private var name: String? = null + /** + * Options set specifically for this node. Specific `OptionKey`s may or may not be present - it is optional. + */ + private var options: MutableMap = mutableMapOf() + /** + * Options set for the subtree, used as the default option when no node-specific options are set. All `OptionKey`s MUST be + * present. + */ + private var subtreeOptions: MutableMap = mutableMapOf() + + /** + * The set of child nodes. + */ + private var _children: MutableSet = mutableSetOf() + val children: MutableSet + get() = _children + /** + * The node configuration for wildcard children + */ + private var wildcardChildren: NodeConfig? = null + + // Property accessors for each option which use the `options` set for the current node + // and fall back to subtree options. + var anyOrderMatch: Config + get() = options[OptionKey.AnyOrderMatch] + ?: subtreeOptions[OptionKey.AnyOrderMatch] + ?: Config(false) + set(value) { options[OptionKey.AnyOrderMatch] = value } + + var collectionEqualCount: Config + get() = options[OptionKey.CollectionEqualCount] + ?: subtreeOptions[OptionKey.CollectionEqualCount] + ?: Config(false) + set(value) { options[OptionKey.CollectionEqualCount] = value } + + var elementCount: Config + get() = options[OptionKey.ElementCount] + ?: subtreeOptions[OptionKey.ElementCount] + ?: Config(true) + set(value) { options[OptionKey.ElementCount] = value } + + var keyMustBeAbsent: Config + get() = options[OptionKey.KeyMustBeAbsent] + ?: subtreeOptions[OptionKey.KeyMustBeAbsent] + ?: Config(false) + set(value) { options[OptionKey.KeyMustBeAbsent] = value } + + var primitiveExactMatch: Config + get() = options[OptionKey.PrimitiveExactMatch] + ?: subtreeOptions[OptionKey.PrimitiveExactMatch] + ?: Config(false) + set(value) { options[OptionKey.PrimitiveExactMatch] = value } + + var valueNotEqual: Config + get() = options[OptionKey.ValueNotEqual] + ?: subtreeOptions[OptionKey.ValueNotEqual] + ?: Config(false) + set(value) { options[OptionKey.ValueNotEqual] = value } + + /** + * Creates a new node with the given values. + * + * Make sure to specify **all** `OptionKey` values for `subtreeOptions`, especially when the node is intended to be the root. + * These subtree options will be used for all descendants unless otherwise specified. If any subtree option keys are missing, + * a default value will be provided. + */ + @JvmOverloads + constructor( + name: String?, + options: MutableMap = mutableMapOf(), + subtreeOptions: MutableMap, + children: MutableSet = mutableSetOf(), + wildcardChildren: NodeConfig? = null + ) { + // Validate subtreeOptions has every option defined + val validatedSubtreeOptions = subtreeOptions.toMutableMap() + OptionKey.values().forEach { key -> + if (!validatedSubtreeOptions.containsKey(key)) { + validatedSubtreeOptions[key] = Config(isActive = false) + } + } + + this.name = name + this.options = options + this.subtreeOptions = validatedSubtreeOptions + this._children = children + this.wildcardChildren = wildcardChildren + } + + companion object { + /** + * Resolves a given node's option using the following precedence: + * 1. Single node config + * a. Child node + * b. Parent's wildcard node + * c. Parent node + * 2. Subtree config + * a. Child node (by definition supersedes wildcard subtree option) + * b. Parent node (only if child node doesn't exist) + * + * This is to handle the case where an array has a node-specific option like AnyPosition match which + * should apply to all direct children (that is, only 1 level down), but one of the children has a + * node specific option disabling AnyPosition match. + */ + @JvmStatic + fun resolveOption(option: OptionKey, childName: String?, parentNode: NodeConfig): Config { + val childNode = parentNode.getChild(childName) + // Single node options + // Current node + childNode?.options?.get(option)?.let { + return it + } + // Parent's wildcard child + parentNode.wildcardChildren?.options?.get(option)?.let { + return it + } + // Check parent array's node-specific option + parentNode.options[option]?.let { + return it + } + // Check subtree options in the same order of precedence, with the condition that if childNode exists, + // it must have a subtree definition. Fallback to parentNode only if childNode doesn't exist. + return childNode?.subtreeOptions?.get(option) ?: parentNode.subtreeOptions[option] ?: Config(false) + } + + @JvmStatic + fun resolveOption(option: OptionKey, childName: Int?, parentNode: NodeConfig): Config { + return resolveOption(option, childName?.toString(), parentNode) + } + } + + /** + * Determines if two `NodeConfig` instances are equal based on their properties. + */ + override fun equals(other: Any?): Boolean = other is NodeConfig && + name == other.name && + options == other.options && + subtreeOptions == other.subtreeOptions + + /** + * Generates a hash code for a `NodeConfig`. + */ + override fun hashCode(): Int = Objects.hash(name, options, subtreeOptions) + + /** + * Creates a deep copy of the current `NodeConfig` instance. + */ + fun deepCopy(): NodeConfig { + return NodeConfig( + name = name, + options = HashMap(options), + subtreeOptions = HashMap(subtreeOptions), + children = _children.map { it.deepCopy() }.toMutableSet(), + wildcardChildren = wildcardChildren?.deepCopy() + ) + } + + /** + * Gets a child node with the specified name. + */ + fun getChild(name: String?): NodeConfig? = _children.firstOrNull { it.name == name } + + /** + * Gets a child node at the specified index if it represents as a string. + */ + fun getChild(index: Int?): NodeConfig? { + return index?.let { + val indexString = it.toString() + _children.firstOrNull { child -> child.name == indexString } + } + } + + /** + * Gets the next node for the given name, falling back to wildcard or asFinalNode if not found. + */ + fun getNextNode(forName: String?): NodeConfig = + getChild(forName) ?: wildcardChildren ?: asFinalNode() + + /** + * Gets the next node for the given index, falling back to wildcard or asFinalNode if not found. + */ + fun getNextNode(forIndex: Int?): NodeConfig = + getChild(forIndex) ?: wildcardChildren ?: asFinalNode() + + /** + * Creates a new NodeConfig instance representing the final node configuration. + * Basically sets the last known subtree options to be used as the default comparison options + * for the rest of the validation. + * + * @return A new NodeConfig instance with the current subtree options. + */ + private fun asFinalNode(): NodeConfig { + // Should not modify self since other recursive function calls may still depend on children. + // Instead, return a new instance with the proper values set + return NodeConfig(name = null, options = mutableMapOf(), subtreeOptions = deepCopySubtreeOptionsWithElementCountReset(subtreeOptions)) + } + + /** + * Provides access to the [Config] for a given option key at the [Scope.SingleNode] level. + */ + fun getSingleNodeOption(key: OptionKey): Config? { + return options[key] + } + + /** + * Provides access to the [Config] for a given option key at the [Scope.Subtree] level. + */ + fun getSubtreeNodeOption(key: OptionKey): Config? { + return subtreeOptions[key] + } + + /** + * Creates or updates nodes based on multiple path configurations. + * This function processes a collection of paths and updates or creates the corresponding nodes. + * + * @param multiPathConfig Configuration for multiple paths including common option key, config, and scope. + */ + fun createOrUpdateNode(multiPathConfig: MultiPathConfig) { + val pathConfigs = multiPathConfig.paths.map { + PathConfig( + path = it, + optionKey = multiPathConfig.optionKey, + config = multiPathConfig.config, + scope = multiPathConfig.scope + ) + } + for (pathConfig in pathConfigs) { + createOrUpdateNode(pathConfig) + } + } + + /** + * Helper method to create or traverse nodes. + * This function processes a single path configuration and updates or creates nodes accordingly. + * + * @param pathConfig Configuration for a single path including option key, config, and scope. + */ + fun createOrUpdateNode(pathConfig: PathConfig) { + val pathComponents = getProcessedPathComponents(pathConfig.path) + updateTree(mutableListOf(this), pathConfig, pathComponents) + } + + /** + * Updates a tree of nodes based on the provided path configuration and path components. + * This function recursively applies configurations to nodes, traversing through the path defined by the path components. + * It supports applying options to individual nodes or entire subtrees based on the scope defined in the path configuration. + * + * @param nodes The list of current nodes to update. + * @param pathConfig The configuration to apply, including the option key and its scope. + * @param pathComponents The components of the path, dictating how deep the configuration should be applied. + */ + private fun updateTree(nodes: MutableList, pathConfig: PathConfig, pathComponents: MutableList) { + if (nodes.isEmpty()) return + // Reached the end of the pathComponents - apply the PathConfig to the current nodes + if (pathComponents.isEmpty()) { + // Apply the node option to the final node + nodes.forEach { node -> + if (pathConfig.scope == Scope.Subtree) { + // Propagate this subtree option update to all children + propagateSubtreeOption(node, pathConfig) + } else { + node.options[pathConfig.optionKey] = pathConfig.config.deepCopy() + } + } + return + } + + // Path components are added in order - the first element is closer to the root + // Ex: "key1[0].key2[23]" -> ["key1", "0" (isArray), "key2", "23" (isArray)] + // Note: there cannot be collisions between array index names as strings and object key names + // as integer strings since the collection type itself during actual traversal prevents this overlap + val pathComponent = pathComponents.removeFirst() + val nextNodes = mutableListOf() + + nodes.forEach { node -> + // Note: wildcard node names are the same as their reserved strings: `*` and `[*]` + pathComponent.name?.let { pathComponentName -> + val child = findOrCreateChild(node, pathComponentName, pathComponent.isWildcard) + nextNodes.add(child) + + // Current path component adds all existing specific index/key name children of the current node + // so that they also get the configuration from the wildcard applied to them too (wildcard is a superset) + if (pathComponent.isWildcard) { + nextNodes.addAll(node._children) + } + } + } + updateTree(nextNodes, pathConfig, pathComponents) + } + + private fun deepCopySubtreeOptionsWithElementCountReset(map: MutableMap): MutableMap { + val deepCopiedSubtreeOptions = map + .mapValues { it.value.deepCopy() } + .toMutableMap() + .apply { + // - Subtree options should always exist, but backup value defaults to false + // - ElementCount's requiredCount value should be removed for nodes that are not explicitly the + // node that had that option set, otherwise the expectation propagates improperly to all children + this[OptionKey.ElementCount] = Config(this[OptionKey.ElementCount]?.isActive ?: true, null) + } + return deepCopiedSubtreeOptions + } + + /** + * Processes the given path string into individual path components with detailed properties. + * This function analyzes a path string, typically representing a navigation path in a structure, + * and breaks it down into components that specify details about how each segment of the path should be treated, + * such as whether it's an array, a wildcard, or requires any specific order handling. + * + * @param pathString The path string to be processed. + * @return A list of [PathComponent] reflecting the structured breakdown of the path string. + */ + private fun getProcessedPathComponents(pathString: String?): MutableList { + val objectPathComponents = getObjectPathComponents(pathString) + val pathComponents = mutableListOf() + for (objectPathComponent in objectPathComponents) { + // Remove escaped dot notations from the path string name provided + val key = objectPathComponent.replace("\\.", ".") + // Extract the string part and array component part(s) from the key string + val (stringComponent, arrayComponents) = getArrayPathComponents(key) + // Process object key path components + stringComponent?.let { + // Check if the current component is the reserved object key wildcard character: `*` + val isWildcard = stringComponent == "*" + pathComponents.add( + PathComponent( + // Remove escape character from escaped wildcard in original path when not interpreted + // as a wildcard + name = if (isWildcard) stringComponent else stringComponent.replace("\\*", "*"), + isAnyOrder = false, + isArray = false, + isWildcard = isWildcard + ) + ) + } + + // Process array path components + for (arrayComponent in arrayComponents) { + // Check if the current component is the reserved array wildcard index sequence: `[*]` + if (arrayComponent == "[*]") { + pathComponents.add( + PathComponent( + name = arrayComponent, + isAnyOrder = false, + isArray = true, + isWildcard = true + ) + ) + } else { + val indexResult = getArrayIndexAndAnyOrder(arrayComponent) + indexResult?.let { + pathComponents.add( + PathComponent( + name = it.first.toString(), + isAnyOrder = it.second, + isArray = true, + isWildcard = false + ) + ) + } + ?: return pathComponents // Test failure emitted by extractIndexAndWildcardStatus + } + } + } + return pathComponents + } + + /** + * Finds or creates a child node within the given node, handling the assignment to the proper descendants' location. + * This method ensures that if the child node already exists, it is returned; otherwise, a new child node is created. + * If a wildcard child node is needed, it either returns an existing wildcard child or creates a new one and assigns it. + * + * @param parentNode The parent node in which to find or create a child. + * @param childNodeName The name of the child node to find or create. + * @param childNodeIsWildcard Indicates whether the child node to be created should be treated as a wildcard node. + * @return The found or newly created child node. + */ + private fun findOrCreateChild(parentNode: NodeConfig, childNodeName: String, childNodeIsWildcard: Boolean): NodeConfig { + return if (childNodeIsWildcard) { + parentNode.wildcardChildren ?: run { + // Apply subtreeOptions to the child + val newChild = NodeConfig(name = childNodeName, subtreeOptions = deepCopySubtreeOptionsWithElementCountReset(parentNode.subtreeOptions)) + parentNode.wildcardChildren = newChild + newChild + } + } else { + parentNode._children.firstOrNull { it.name == childNodeName } ?: run { + // If a wildcard child already exists, use that as the base, deep copying its existing setup + parentNode.wildcardChildren?.deepCopy()?.apply { + this.name = childNodeName + parentNode._children.add(this) + // If a wildcard child doesn't exist, create a new child from scratch + } ?: run { + // Apply subtreeOptions to the child + val newChild = NodeConfig(name = childNodeName, subtreeOptions = deepCopySubtreeOptionsWithElementCountReset(parentNode.subtreeOptions)) + parentNode._children.add(newChild) + newChild + } + } + } + } + + /** + * Propagates a subtree option from the given path configuration to the specified node and all its descendants. + * In the ElementCount case, removes the element count assertion when propagating to child nodes. + * + * @param node The node from which to start propagating the subtree option. + * @param pathConfig The configuration containing the option to propagate. + */ + private fun propagateSubtreeOption(node: NodeConfig, pathConfig: PathConfig) { + val key = pathConfig.optionKey + // Set the subtree configuration for the current node and its wildcard config (if it exists) + node.subtreeOptions[key] = pathConfig.config.deepCopy() + // A non-null elementCount means the ElementCount assertion is active at the given node; + // however, child nodes (including wildcard children) should not inherit this assertion. + // The element counter is set to null so that while the direct path target of the subtree + // option has the counter applied, this assertion is not propagated to any children. + val elementCountRemovedConfig = Config(pathConfig.config.isActive, null) + node.wildcardChildren?.subtreeOptions?.set(key, elementCountRemovedConfig) + val elementCountRemovedPathConfig = PathConfig(elementCountRemovedConfig, pathConfig.scope, pathConfig.path, pathConfig.optionKey) + for (child in node._children) { + // Only propagate the subtree value for the specific option key, + // otherwise, previously set subtree values will be reset to the default values + child.subtreeOptions[key] = elementCountRemovedPathConfig.config + propagateSubtreeOption(child, elementCountRemovedPathConfig) + } + } + + /** + * Extracts and returns a pair with a valid index and a flag indicating whether it's an `AnyOrder` index from a single array path segment. + * + * This method considers a key that matches the array access format (ex: `[*123]` or `[123]`). + * It identifies an index by optionally checking for the wildcard marker `*`. + * + * @param pathComponent A single path component which may contain a potential index with or without a wildcard marker. + * @return A Pair containing an optional valid `Int` index and a boolean indicating whether it's a wildcard index, + * returns `null` if no valid index is found. + * + * Note: + * Examples of conversions: + * - `[*123]` -> Pair(123, true) + * - `[123]` -> Pair(123, false) + * - `[*ab12]` causes a failure since "ab12" is not a valid integer. + */ + private fun getArrayIndexAndAnyOrder(pathComponent: String): Pair? { + val arrayIndexValueRegex = "^\\[(.*?)\\]$".toRegex() + val arrayIndexValue = arrayIndexValueRegex.find(pathComponent)?.groupValues?.get(1) + + if (arrayIndexValue == null) { + fail("Error: unable to find valid index value from path component: $pathComponent") + return null + } + + val isAnyOrder = arrayIndexValue.startsWith("*") + val indexString = if (isAnyOrder) arrayIndexValue.drop(1) else arrayIndexValue + + val validIndex = indexString.toIntOrNull() + if (validIndex == null) { + fail("Error: Index is not a valid Int: $indexString") + return null + } + + return Pair(validIndex, isAnyOrder) + } + + /** + * Breaks a path string into its nested *object* segments. Any trailing *array* style access components are bundled with a + * preceding object segment (if the object segment exists). + * + * For example, the key path: `"key0\.key1.key2[1][2].key3"`, represents a path to an element in a nested + * JSON structure. The result for the input is: `["key0\.key1", "key2[1][2]", "key3"]`. + * + * The method breaks each object path segment separated by the `.` character and escapes + * the sequence `\.` as a part of the key itself (that is, it ignores `\.` as a nesting indicator). + * + * @param path The key path string to be split into its nested object segments. + * @return A list of strings representing the individual components of the key path. If the input `path` is null or empty, + * a list containing an empty string is returned. If no components are found, an empty list is returned. + */ + fun getObjectPathComponents(path: String?): List { + // Handle edge case where input is null + if (path == null) { + return emptyList() + } + // Handle edge case where input is empty + if (path.isEmpty()) return listOf("") + + val segments = mutableListOf() + var startIndex = 0 + var inEscapeSequence = false + + // Iterate over each character in the input string with its index + path.forEachIndexed { index, char -> + when { + char == '\\' -> inEscapeSequence = true + char == '.' && !inEscapeSequence -> { + // Add the segment from the start index to current index (excluding the dot) + segments.add(path.substring(startIndex, index)) + + // Update the start index for the next segment + startIndex = index + 1 + } + else -> inEscapeSequence = false + } + } + + // Add the remaining segment after the last dot (if any) + segments.add(path.substring(startIndex)) + + // Handle edge case where input ends with a dot (but not an escaped dot) + if (path.endsWith(".") && !path.endsWith("\\.") && segments.last().isNotEmpty()) { + segments.add("") + } + + return segments + } + + /** + * Extracts valid array format access components from a given path component and returns the separated components. + * + * Given `"key1[0][1]"`, the result is `["key1", "[0]", "[1]"]`. + * Array format access can be escaped using a backslash character preceding an array bracket. Valid bracket escape sequences are cleaned so + * that the final path component does not have the escape character. + * For example: `"key1\[0\]"` results in the single path component `"key1[0]"`. + * + * @param pathComponent The path component to be split into separate components given valid array formatted components. + * @return A Pair containing the string component of the path, if any, and a list of string path components representing + * the individual elements of the array accesses, if present. + */ + fun getArrayPathComponents(pathComponent: String): Pair> { + // Handle edge case where input is empty + if (pathComponent.isEmpty()) return Pair("", listOf()) + + var stringComponent = "" + val arrayComponents = mutableListOf() + var bracketCount = 0 + var componentBuilder = StringBuilder() + var lastArrayAccessEnd = pathComponent.length // to track the end of the last valid array-style access + + fun isNextCharBackslash(index: Int): Boolean { + if (index == 0) { + // There is no character before the startIndex. + return false + } + // Since we're iterating in reverse, the "next" character is before i + return pathComponent[index - 1] == '\\' + } + + for (index in pathComponent.indices.reversed()) { + when { + pathComponent[index] == ']' && !isNextCharBackslash(index) -> { + bracketCount += 1 + componentBuilder.append("]") + } + pathComponent[index] == '[' && !isNextCharBackslash(index) -> { + bracketCount -= 1 + componentBuilder.append("[") + if (bracketCount == 0) { + arrayComponents.add(0, componentBuilder.toString().reversed()) + componentBuilder.clear() + lastArrayAccessEnd = index + } + } + pathComponent[index] == '\\' -> { + componentBuilder.append('\\') + } + bracketCount == 0 && index < lastArrayAccessEnd -> { + stringComponent = pathComponent.substring(0, index + 1) + break + } + else -> componentBuilder.append(pathComponent[index]) + } + } + + // Add any remaining component that's not yet added + if (componentBuilder.isNotEmpty()) { + stringComponent = componentBuilder.toString().reversed() + } + if (stringComponent.isNotEmpty()) { + stringComponent = stringComponent + .replace("\\[", "[") + .replace("\\]", "]") + } + + if (lastArrayAccessEnd == 0) { + return Pair(null, arrayComponents) + } + return Pair(stringComponent, arrayComponents) + } +} diff --git a/code/testutils/src/main/java/com/adobe/marketing/mobile/util/RealNetworkService.kt b/code/testutils/src/main/java/com/adobe/marketing/mobile/util/RealNetworkService.kt new file mode 100644 index 000000000..5523924c8 --- /dev/null +++ b/code/testutils/src/main/java/com/adobe/marketing/mobile/util/RealNetworkService.kt @@ -0,0 +1,174 @@ +/* + 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.services.HttpConnecting +import com.adobe.marketing.mobile.services.HttpMethod +import com.adobe.marketing.mobile.services.Log +import com.adobe.marketing.mobile.services.NetworkCallback +import com.adobe.marketing.mobile.services.NetworkRequest +import com.adobe.marketing.mobile.services.NetworkServiceHelper +import com.adobe.marketing.mobile.services.TestableNetworkRequest + +/** + * An override of `NetworkService` used for tests that require real outgoing network requests. Provides + * methods to set expectations on network requests and perform assertions against those expectations. + */ +class RealNetworkService : NetworkServiceHelper() { + private val helper = NetworkRequestHelper() + /** + * Flag that indicates if the [connectAsync] method was called. + * Note that this property does not await and returns the status immediately. + */ + val connectAsyncCalled: Boolean + get() { + // Assumes that `NetworkRequestHelper.recordSentNetworkRequest` is always called by `connectAsync`. + // If this assumption changes, this flag logic needs to be updated. + return helper.networkRequests.isNotEmpty() + } + /** + * How many times the [connectAsync] method was called. + * Note that this property does not await and returns the value immediately. + */ + val connectAsyncCallCount: Int + get() { + // Assumes that `NetworkRequestHelper.recordSentNetworkRequest` is always called by `connectAsync`. + // If this assumption changes, this flag logic needs to be updated. + return helper.networkRequests.count() + } + + companion object { + private const val LOG_SOURCE = "RealNetworkService" + } + + override fun connectAsync(request: NetworkRequest?, callback: NetworkCallback?) { + val request = TestableNetworkRequest.from(request) + if (request == null) { + Log.error( + TestConstants.LOG_TAG, + LOG_SOURCE, + "Received null network request. Early exiting connectAsync method." + ) + return + } + helper.recordNetworkRequest(request) + super.connectAsync(request) { + helper.addResponseFor(request, it) + helper.countDownExpected(request) + + callback?.call(it) + } + } + + /** + * Immediately returns the associated responses (if any) for the provided network request **without awaiting**. + * + * Note: To properly await network responses for a given request, make sure to set an expectation + * using [setExpectationForNetworkRequest] then await the expectation using [assertAllNetworkRequestExpectations]. + * + * @param request The [NetworkRequest] for which the response should be returned. + * @return The list of [HttpConnecting] responses for the given request or `null` if not found. + * @see [setExpectationForNetworkRequest] + * @see [assertAllNetworkRequestExpectations] + */ + fun getResponsesFor(request: NetworkRequest): List? { + return TestableNetworkRequest.from(request)?.let { + helper.getResponsesFor(it) + } + } + + // Passthrough for shared helper APIs + /** + * Asserts that the correct number of network requests were sent based on previously set expectations. + * + * @throws InterruptedException If the current thread is interrupted while waiting. + * @see [setExpectationForNetworkRequest] + */ + fun assertAllNetworkRequestExpectations( + ignoreUnexpectedRequests: Boolean = true, + waitForUnexpectedRequests: Boolean = true, + timeoutMillis: Int = TestConstants.Defaults.WAIT_NETWORK_REQUEST_TIMEOUT_MS + ) { + helper.assertAllNetworkRequestExpectations(ignoreUnexpectedRequests, waitForUnexpectedRequests, timeoutMillis) + } + + /** + * Immediately returns all sent network requests (if any) **without awaiting**. + */ + fun getAllNetworkRequests(): List { + return helper.networkRequests + } + + /** + * Returns the network request(s) sent through the Core NetworkService , or an empty list if none was found. + * + * Use this method after calling [setExpectationForNetworkRequest] to wait for expected requests. + * + * @param url The URL `String` of the [NetworkRequest] to get. + * @param method The [HttpMethod] of the [NetworkRequest] to get. + * @param timeoutMillis The duration (in milliseconds) to wait for the expected network requests before + * timing out. Defaults to [TestConstants.Defaults.WAIT_NETWORK_REQUEST_TIMEOUT_MS]. + * + * @return A list of [TestableNetworkRequest]s that match the provided [url] and [method]. Returns + * an empty list if no matching requests were dispatched. + * + * @throws InterruptedException If the current thread is interrupted while waiting. + * + * @see setExpectationForNetworkRequest + */ + @Throws(InterruptedException::class) + @JvmOverloads + fun getNetworkRequestsWith( + url: String, + method: HttpMethod, + timeoutMillis: Int = TestConstants.Defaults.WAIT_NETWORK_REQUEST_TIMEOUT_MS + ): List { + return helper.getNetworkRequestsWith(url, method, timeoutMillis) + } + + /** + * Clears all test expectations and recorded network requests and responses. + */ + fun reset() { + helper.reset() + } + + /** + * Sets the expected number of times a network request should be sent. + * + * @param url The URL `String` of the [NetworkRequest] for which the expectation is set. + * @param method The [HttpMethod] of the [NetworkRequest] for which the expectation is set. + * @param expectedCount The number of times the request is expected to be sent. + */ + fun setExpectationForNetworkRequest( + url: String, + method: HttpMethod, + expectedCount: Int + ) { + setExpectationForNetworkRequest(TestableNetworkRequest(url, method), expectedCount) + } + + /** + * Sets the expected number of times a network request should be sent. + * + * @param networkRequest The [NetworkRequest] for which the expectation is set. + * @param expectedCount The number of times the request is expected to be sent. + */ + fun setExpectationForNetworkRequest( + networkRequest: NetworkRequest, + expectedCount: Int + ) { + TestableNetworkRequest.from(networkRequest)?.let { + helper.setExpectationFor(it, expectedCount) + } + } +} diff --git a/code/testutils/src/main/java/com/adobe/marketing/mobile/util/TestConstants.java b/code/testutils/src/main/java/com/adobe/marketing/mobile/util/TestConstants.java new file mode 100644 index 000000000..3eaa3731e --- /dev/null +++ b/code/testutils/src/main/java/com/adobe/marketing/mobile/util/TestConstants.java @@ -0,0 +1,150 @@ +/* + Copyright 2022 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; + +public class TestConstants { + + public static final String EVENT_NAME_REQUEST_CONTENT = "AEP Request Event"; + public static final String EVENT_NAME_RESPONSE_CONTENT = "AEP Response Event Handle"; + public static final String EVENT_NAME_ERROR_RESPONSE_CONTENT = "AEP Error Response"; + public static final int NETWORK_REQUEST_MAX_RETRIES = 5; + public static final String EDGE_DATA_STORAGE = "EdgeDataStorage"; + public static final String LOG_TAG = "FunctionalTestsFramework"; + public static final String EXTENSION_NAME = "com.adobe.edge"; + + // Event type and sources used by Monitor Extension + public static class EventType { + + public static final String CONFIGURATION = "com.adobe.eventType.configuration"; + public static final String EDGE = "com.adobe.eventType.edge"; + public static final String MONITOR = "com.adobe.functional.eventType.monitor"; + + private EventType() {} + } + + public static class EventSource { + + public static final String ERROR_RESPONSE_CONTENT = "com.adobe.eventSource.errorResponseContent"; + public static final String LOCATION_HINT_RESULT = "locationHint:result"; + public static final String RESPONSE_CONTENT = "com.adobe.eventSource.responseContent"; + public static final String SHARED_STATE_REQUEST = "com.adobe.eventSource.sharedStateRequest"; + public static final String SHARED_STATE_RESPONSE = "com.adobe.eventSource.sharedStateResponse"; + public static final String STATE_STORE = "state:store"; + public static final String UNREGISTER = "com.adobe.eventSource.unregister"; + public static final String XDM_SHARED_STATE_REQUEST = "com.adobe.eventSource.xdmsharedStateRequest"; + public static final String XDM_SHARED_STATE_RESPONSE = "com.adobe.eventSource.xdmsharedStateResponse"; + + private EventSource() {} + } + + public static class Defaults { + + public static final int WAIT_TIMEOUT_MS = 1000; + public static final int WAIT_NETWORK_REQUEST_TIMEOUT_MS = 2000; + public static final int WAIT_EVENT_TIMEOUT_MS = 2000; + public static final int WAIT_SHARED_STATE_TIMEOUT_MS = 5000; + + public static final String EXEDGE_INTERACT_URL_STRING = "https://edge.adobedc.net/ee/v1/interact"; + public static final String EXEDGE_INTERACT_OR2_LOC_URL_STRING = "https://edge.adobedc.net/ee/or2/v1/interact"; + public static final String EXEDGE_INTERACT_PRE_PROD_URL_STRING = + "https://edge.adobedc.net/ee-pre-prd/v1/interact"; + public static final String EXEDGE_INTERACT_INT_URL_STRING = "https://edge-int.adobedc.net/ee/v1/interact"; + + public static final String EXEDGE_CONSENT_URL_STRING = "https://edge.adobedc.net/ee/v1/privacy/set-consent"; + public static final String EXEDGE_CONSENT_PRE_PROD_URL_STRING = + "https://edge.adobedc.net/ee-pre-prd/v1/privacy/set-consent"; + public static final String EXEDGE_CONSENT_INT_URL_STRING = + "https://edge-int.adobedc.net/ee/v1/privacy/set-consent"; + public static final String EXEDGE_MEDIA_PROD_URL_STRING = "https://edge.adobedc.net/ee/va/v1/sessionstart"; + public static final String EXEDGE_MEDIA_OR2_LOC_URL_STRING = + "https://edge.adobedc.net/ee/or2/va/v1/sessionstart"; + + private Defaults() {} + } + + public static class EventDataKey { + + public static final String EDGE_REQUEST_ID = "requestId"; + public static final String REQUEST_EVENT_ID = "requestEventId"; + public static final String DATASET_ID = "datasetId"; + // Used by Monitor Extension + public static final String STATE_OWNER = "stateowner"; + + private EventDataKey() {} + } + + public static class DataStoreKey { + + public static final String CONFIG_DATASTORE = "AdobeMobile_ConfigState"; + public static final String IDENTITY_DATASTORE = "com.adobe.edge.identity"; + public static final String IDENTITY_DIRECT_DATASTORE = "visitorIDServiceDataStore"; + public static final String STORE_PAYLOADS = "storePayloads"; + + private DataStoreKey() {} + } + + public static class SharedState { + + public static final String STATE_OWNER = "stateowner"; + public static final String EDGE = "com.adobe.edge"; + public static final String CONFIGURATION = "com.adobe.module.configuration"; + public static final String CONSENT = "com.adobe.edge.consent"; + public static final String ASSURANCE = "com.adobe.assurance"; + public static final String IDENTITY = "com.adobe.module.identity"; + public static final String LIFECYCLE = "com.adobe.module.lifecycle"; + + class Configuration { + + public static final String EDGE_CONFIG_ID = "edge.configId"; + + private Configuration() {} + } + + class Identity { + + public static final String ECID = "mid"; + public static final String BLOB = "blob"; + public static final String LOCATION_HINT = "locationhint"; + public static final String VISITOR_IDS_LIST = "visitoridslist"; + + private Identity() {} + } + + class Assurance { + + public static final String INTEGRATION_ID = "integrationid"; + + private Assurance() {} + } + + private SharedState() {} + } + + public static class NetworkKeys { + + public static final String REQUEST_URL = "https://edge.adobedc.net/ee/v1"; + public static final String REQUEST_PARAMETER_KEY_CONFIG_ID = "configId"; + public static final String REQUEST_PARAMETER_KEY_REQUEST_ID = "requestId"; + public static final String REQUEST_HEADER_KEY_REQUEST_ID = "X-Request-ID"; + + public static final String HEADER_KEY_AEP_VALIDATION_TOKEN = "X-Adobe-AEP-Validation-Token"; + public static final int DEFAULT_CONNECT_TIMEOUT_SECONDS = 5; + public static final int DEFAULT_READ_TIMEOUT_SECONDS = 5; + public static final String HEADER_KEY_ACCEPT = "accept"; + public static final String HEADER_KEY_CONTENT_TYPE = "Content-Type"; + public static final String HEADER_VALUE_APPLICATION_JSON = "application/json"; + + private NetworkKeys() {} + } + + private TestConstants() {} +} diff --git a/code/testutils/src/main/java/com/adobe/marketing/mobile/util/TestHelper.java b/code/testutils/src/main/java/com/adobe/marketing/mobile/util/TestHelper.java new file mode 100644 index 000000000..c1d1c6f01 --- /dev/null +++ b/code/testutils/src/main/java/com/adobe/marketing/mobile/util/TestHelper.java @@ -0,0 +1,711 @@ +/* + 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.MonitorExtension.EventSpec; +import static com.adobe.marketing.mobile.util.TestConstants.LOG_TAG; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import android.app.Application; +import android.app.Instrumentation; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.AssetManager; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.test.platform.app.InstrumentationRegistry; +import com.adobe.marketing.mobile.AdobeCallbackWithError; +import com.adobe.marketing.mobile.AdobeError; +import com.adobe.marketing.mobile.Event; +import com.adobe.marketing.mobile.Extension; +import com.adobe.marketing.mobile.LoggingMode; +import com.adobe.marketing.mobile.MobileCore; +import com.adobe.marketing.mobile.MobileCoreHelper; +import com.adobe.marketing.mobile.services.Log; +import com.adobe.marketing.mobile.services.MockDataStoreService; +import com.adobe.marketing.mobile.services.ServiceProvider; +import com.adobe.marketing.mobile.services.ServiceProviderHelper; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; + +public class TestHelper { + + private static final String LOG_SOURCE = "TestHelper"; + private static Application defaultApplication; + + // List of threads to wait for after test execution + private static final List 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 (Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;)V + public fun (Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;[Ljava/lang/String;)V + public fun (Ljava/util/List;)V + public fun (Z)V + public fun (ZLcom/adobe/marketing/mobile/util/NodeConfig$Scope;)V + public fun (ZLcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;)V + public synthetic fun (ZLcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZLcom/adobe/marketing/mobile/util/NodeConfig$Scope;[Ljava/lang/String;)V + public synthetic fun (ZLcom/adobe/marketing/mobile/util/NodeConfig$Scope;[Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZLjava/util/List;)V + public fun (Z[Ljava/lang/String;)V + public fun ([Ljava/lang/String;)V + public final fun component1 ()Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public final fun component2 ()Lcom/adobe/marketing/mobile/util/NodeConfig$Scope; + public final fun component3 ()Ljava/util/List; + public final fun component4 ()Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey; + public final fun copy (Lcom/adobe/marketing/mobile/util/NodeConfig$Config;Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey;)Lcom/adobe/marketing/mobile/util/AnyOrderMatch; + public static synthetic fun copy$default (Lcom/adobe/marketing/mobile/util/AnyOrderMatch;Lcom/adobe/marketing/mobile/util/NodeConfig$Config;Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey;ILjava/lang/Object;)Lcom/adobe/marketing/mobile/util/AnyOrderMatch; + public fun equals (Ljava/lang/Object;)Z + public fun getConfig ()Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public fun getOptionKey ()Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey; + public fun getPaths ()Ljava/util/List; + public fun getScope ()Lcom/adobe/marketing/mobile/util/NodeConfig$Scope; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/adobe/marketing/mobile/util/AnyOrderMatch$Companion { +} + +public final class com/adobe/marketing/mobile/util/CollectionEqualCount : com/adobe/marketing/mobile/util/MultiPathConfig { + public static final field Companion Lcom/adobe/marketing/mobile/util/CollectionEqualCount$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 (Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;)V + public fun (Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;[Ljava/lang/String;)V + public fun (Ljava/util/List;)V + public fun (Z)V + public fun (ZLcom/adobe/marketing/mobile/util/NodeConfig$Scope;)V + public fun (ZLcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;)V + public synthetic fun (ZLcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZLcom/adobe/marketing/mobile/util/NodeConfig$Scope;[Ljava/lang/String;)V + public synthetic fun (ZLcom/adobe/marketing/mobile/util/NodeConfig$Scope;[Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZLjava/util/List;)V + public fun (Z[Ljava/lang/String;)V + public fun ([Ljava/lang/String;)V + public final fun component1 ()Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public final fun component2 ()Lcom/adobe/marketing/mobile/util/NodeConfig$Scope; + public final fun component3 ()Ljava/util/List; + public final fun component4 ()Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey; + public final fun copy (Lcom/adobe/marketing/mobile/util/NodeConfig$Config;Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey;)Lcom/adobe/marketing/mobile/util/CollectionEqualCount; + public static synthetic fun copy$default (Lcom/adobe/marketing/mobile/util/CollectionEqualCount;Lcom/adobe/marketing/mobile/util/NodeConfig$Config;Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey;ILjava/lang/Object;)Lcom/adobe/marketing/mobile/util/CollectionEqualCount; + public fun equals (Ljava/lang/Object;)Z + public fun getConfig ()Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public fun getOptionKey ()Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey; + public fun getPaths ()Ljava/util/List; + public fun getScope ()Lcom/adobe/marketing/mobile/util/NodeConfig$Scope; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/adobe/marketing/mobile/util/CollectionEqualCount$Companion { +} + +public final class com/adobe/marketing/mobile/util/ElementCount : com/adobe/marketing/mobile/util/MultiPathConfig { + public static final field Companion Lcom/adobe/marketing/mobile/util/ElementCount$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 (Ljava/lang/Integer;Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;)V + public fun (Ljava/lang/Integer;Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;)V + public fun (Ljava/lang/Integer;Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;[Ljava/lang/String;)V + public fun (Ljava/lang/Integer;Ljava/util/List;)V + public fun (Ljava/lang/Integer;Z)V + public fun (Ljava/lang/Integer;ZLcom/adobe/marketing/mobile/util/NodeConfig$Scope;)V + public fun (Ljava/lang/Integer;ZLcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;)V + public synthetic fun (Ljava/lang/Integer;ZLcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/Integer;ZLcom/adobe/marketing/mobile/util/NodeConfig$Scope;[Ljava/lang/String;)V + public synthetic fun (Ljava/lang/Integer;ZLcom/adobe/marketing/mobile/util/NodeConfig$Scope;[Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/Integer;ZLjava/util/List;)V + public fun (Ljava/lang/Integer;Z[Ljava/lang/String;)V + public fun (Ljava/lang/Integer;[Ljava/lang/String;)V + public final fun component1 ()Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public final fun component2 ()Lcom/adobe/marketing/mobile/util/NodeConfig$Scope; + public final fun component3 ()Ljava/util/List; + public final fun component4 ()Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey; + public final fun copy (Lcom/adobe/marketing/mobile/util/NodeConfig$Config;Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey;)Lcom/adobe/marketing/mobile/util/ElementCount; + public static synthetic fun copy$default (Lcom/adobe/marketing/mobile/util/ElementCount;Lcom/adobe/marketing/mobile/util/NodeConfig$Config;Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey;ILjava/lang/Object;)Lcom/adobe/marketing/mobile/util/ElementCount; + public fun equals (Ljava/lang/Object;)Z + public fun getConfig ()Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public fun getOptionKey ()Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey; + public fun getPaths ()Ljava/util/List; + public fun getScope ()Lcom/adobe/marketing/mobile/util/NodeConfig$Scope; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/adobe/marketing/mobile/util/ElementCount$Companion { +} + +public class com/adobe/marketing/mobile/util/FakeNamedCollection : 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 final class com/adobe/marketing/mobile/util/JSONAsserts { + public static final field INSTANCE Lcom/adobe/marketing/mobile/util/JSONAsserts; + public static final fun assertEquals (Ljava/lang/Object;Ljava/lang/Object;)V + public static final fun assertExactMatch (Ljava/lang/Object;Ljava/lang/Object;Ljava/util/List;)V + public static final fun assertExactMatch (Ljava/lang/Object;Ljava/lang/Object;[Lcom/adobe/marketing/mobile/util/MultiPathConfig;)V + public static final fun assertTypeMatch (Ljava/lang/Object;Ljava/lang/Object;Ljava/util/List;)V + public static final fun assertTypeMatch (Ljava/lang/Object;Ljava/lang/Object;[Lcom/adobe/marketing/mobile/util/MultiPathConfig;)V + public final fun getJSONRepresentation (Ljava/lang/Object;)Ljava/lang/Object; +} + +public final class com/adobe/marketing/mobile/util/JSONAsserts$ValidationResult { + public fun (ZI)V + public final fun component1 ()Z + public final fun component2 ()I + public final fun copy (ZI)Lcom/adobe/marketing/mobile/util/JSONAsserts$ValidationResult; + public static synthetic fun copy$default (Lcom/adobe/marketing/mobile/util/JSONAsserts$ValidationResult;ZIILjava/lang/Object;)Lcom/adobe/marketing/mobile/util/JSONAsserts$ValidationResult; + public fun equals (Ljava/lang/Object;)Z + public final fun getElementCount ()I + public fun hashCode ()I + public final fun isValid ()Z + public fun toString ()Ljava/lang/String; +} + +public final class com/adobe/marketing/mobile/util/KeyMustBeAbsent : com/adobe/marketing/mobile/util/MultiPathConfig { + public static final field Companion Lcom/adobe/marketing/mobile/util/KeyMustBeAbsent$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 (Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;)V + public fun (Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;[Ljava/lang/String;)V + public fun (Ljava/util/List;)V + public fun (Z)V + public fun (ZLcom/adobe/marketing/mobile/util/NodeConfig$Scope;)V + public fun (ZLcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;)V + public synthetic fun (ZLcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZLcom/adobe/marketing/mobile/util/NodeConfig$Scope;[Ljava/lang/String;)V + public synthetic fun (ZLcom/adobe/marketing/mobile/util/NodeConfig$Scope;[Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZLjava/util/List;)V + public fun (Z[Ljava/lang/String;)V + public fun ([Ljava/lang/String;)V + public final fun component1 ()Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public final fun component2 ()Lcom/adobe/marketing/mobile/util/NodeConfig$Scope; + public final fun component3 ()Ljava/util/List; + public final fun component4 ()Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey; + public final fun copy (Lcom/adobe/marketing/mobile/util/NodeConfig$Config;Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey;)Lcom/adobe/marketing/mobile/util/KeyMustBeAbsent; + public static synthetic fun copy$default (Lcom/adobe/marketing/mobile/util/KeyMustBeAbsent;Lcom/adobe/marketing/mobile/util/NodeConfig$Config;Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey;ILjava/lang/Object;)Lcom/adobe/marketing/mobile/util/KeyMustBeAbsent; + public fun equals (Ljava/lang/Object;)Z + public fun getConfig ()Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public fun getOptionKey ()Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey; + public fun getPaths ()Ljava/util/List; + public fun getScope ()Lcom/adobe/marketing/mobile/util/NodeConfig$Scope; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/adobe/marketing/mobile/util/KeyMustBeAbsent$Companion { +} + +public class com/adobe/marketing/mobile/util/MockConnection : com/adobe/marketing/mobile/services/HttpConnecting { + public field closeCalledTimes I + public field getErrorStreamCalledTimes I + public field getInputStreamCalledTimes I + public field getResponseCodeCalledTimes I + public fun (ILjava/lang/String;Ljava/lang/String;)V + public fun (ILjava/lang/String;Ljava/lang/String;Ljava/util/Map;)V + public fun close ()V + public fun getErrorStream ()Ljava/io/InputStream; + public fun getInputStream ()Ljava/io/InputStream; + public fun getResponseCode ()I + public fun getResponseMessage ()Ljava/lang/String; + public fun getResponsePropertyValue (Ljava/lang/String;)Ljava/lang/String; +} + +public final class com/adobe/marketing/mobile/util/MockNetworkService : com/adobe/marketing/mobile/services/Networking { + public static final field Companion Lcom/adobe/marketing/mobile/util/MockNetworkService$Companion; + public fun ()V + public final fun assertAllNetworkRequestExpectations ()V + public final fun assertAllNetworkRequestExpectations (Z)V + public final fun assertAllNetworkRequestExpectations (ZZ)V + public final fun assertAllNetworkRequestExpectations (ZZI)V + public static synthetic fun assertAllNetworkRequestExpectations$default (Lcom/adobe/marketing/mobile/util/MockNetworkService;ZZIILjava/lang/Object;)V + public fun connectAsync (Lcom/adobe/marketing/mobile/services/NetworkRequest;Lcom/adobe/marketing/mobile/services/NetworkCallback;)V + public final fun createMockNetworkResponse (Ljava/lang/String;I)Lcom/adobe/marketing/mobile/services/HttpConnecting; + public final fun createMockNetworkResponse (Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;Ljava/util/Map;)Lcom/adobe/marketing/mobile/services/HttpConnecting; + public final fun enableNetworkResponseDelay (I)V + public final fun getAllNetworkRequests (I)Ljava/util/List; + public static synthetic fun getAllNetworkRequests$default (Lcom/adobe/marketing/mobile/util/MockNetworkService;IILjava/lang/Object;)Ljava/util/List; + public final fun getConnectAsyncCallCount ()I + public final fun getConnectAsyncCalled ()Z + public final fun getNetworkRequestsWith (Ljava/lang/String;Lcom/adobe/marketing/mobile/services/HttpMethod;)Ljava/util/List; + public final fun getNetworkRequestsWith (Ljava/lang/String;Lcom/adobe/marketing/mobile/services/HttpMethod;I)Ljava/util/List; + public static synthetic fun getNetworkRequestsWith$default (Lcom/adobe/marketing/mobile/util/MockNetworkService;Ljava/lang/String;Lcom/adobe/marketing/mobile/services/HttpMethod;IILjava/lang/Object;)Ljava/util/List; + public final fun reset ()V + public final fun setDefaultResponse (Lcom/adobe/marketing/mobile/services/HttpConnecting;)V + public final fun setExpectationForNetworkRequest (Ljava/lang/String;Lcom/adobe/marketing/mobile/services/HttpMethod;I)V + public final fun setMockResponseFor (Ljava/lang/String;Lcom/adobe/marketing/mobile/services/HttpConnecting;)V + public final fun setMockResponseFor (Ljava/lang/String;Lcom/adobe/marketing/mobile/services/HttpMethod;Lcom/adobe/marketing/mobile/services/HttpConnecting;)V + public static synthetic fun setMockResponseFor$default (Lcom/adobe/marketing/mobile/util/MockNetworkService;Ljava/lang/String;Lcom/adobe/marketing/mobile/services/HttpMethod;Lcom/adobe/marketing/mobile/services/HttpConnecting;ILjava/lang/Object;)V +} + +public final class com/adobe/marketing/mobile/util/MockNetworkService$Companion { +} + +public class com/adobe/marketing/mobile/util/MonitorExtension : com/adobe/marketing/mobile/Extension { + public static final field EXTENSION Ljava/lang/Class; + protected fun (Lcom/adobe/marketing/mobile/ExtensionApi;)V + public static fun getExpectedEvents ()Ljava/util/Map; + protected fun getName ()Ljava/lang/String; + public static fun getReceivedEvents ()Ljava/util/Map; + protected fun onRegistered ()V + public static fun reset ()V + public static fun setExpectedEvent (Ljava/lang/String;Ljava/lang/String;I)V + public static fun unregisterExtension ()V + public fun wildcardProcessor (Lcom/adobe/marketing/mobile/Event;)V +} + +public class com/adobe/marketing/mobile/util/MonitorExtension$EventSpec { + public final field source Ljava/lang/String; + public final field type Ljava/lang/String; + public fun (Ljava/lang/String;Ljava/lang/String;)V + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class com/adobe/marketing/mobile/util/MultiPathConfig { + public abstract fun getConfig ()Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public abstract fun getOptionKey ()Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey; + public abstract fun getPaths ()Ljava/util/List; + public abstract fun getScope ()Lcom/adobe/marketing/mobile/util/NodeConfig$Scope; +} + +public final class com/adobe/marketing/mobile/util/NetworkRequestHelper { + public static final field Companion Lcom/adobe/marketing/mobile/util/NetworkRequestHelper$Companion; + public fun ()V + public final fun addResponseFor (Lcom/adobe/marketing/mobile/services/TestableNetworkRequest;Lcom/adobe/marketing/mobile/services/HttpConnecting;)V + public final fun assertAllNetworkRequestExpectations (ZZI)V + public static synthetic fun assertAllNetworkRequestExpectations$default (Lcom/adobe/marketing/mobile/util/NetworkRequestHelper;ZZIILjava/lang/Object;)V + public final fun awaitRequest (Lcom/adobe/marketing/mobile/services/TestableNetworkRequest;IZ)V + public static synthetic fun awaitRequest$default (Lcom/adobe/marketing/mobile/util/NetworkRequestHelper;Lcom/adobe/marketing/mobile/services/TestableNetworkRequest;IZILjava/lang/Object;)V + public final fun countDownExpected (Lcom/adobe/marketing/mobile/services/TestableNetworkRequest;)V + public final fun getNetworkRequests ()Ljava/util/List; + public final fun getNetworkRequestsWith (Ljava/lang/String;Lcom/adobe/marketing/mobile/services/HttpMethod;I)Ljava/util/List; + public static synthetic fun getNetworkRequestsWith$default (Lcom/adobe/marketing/mobile/util/NetworkRequestHelper;Ljava/lang/String;Lcom/adobe/marketing/mobile/services/HttpMethod;IILjava/lang/Object;)Ljava/util/List; + public final fun getNetworkResponses ()Ljava/util/Map; + public final fun getRequestsMatching (Lcom/adobe/marketing/mobile/services/TestableNetworkRequest;)Ljava/util/List; + public final fun getResponsesFor (Lcom/adobe/marketing/mobile/services/TestableNetworkRequest;)Ljava/util/List; + public final fun recordNetworkRequest (Lcom/adobe/marketing/mobile/services/TestableNetworkRequest;)V + public final fun removeResponsesFor (Lcom/adobe/marketing/mobile/services/TestableNetworkRequest;)V + public final fun reset ()V + public final fun setExpectationFor (Lcom/adobe/marketing/mobile/services/TestableNetworkRequest;I)V +} + +public final class com/adobe/marketing/mobile/util/NetworkRequestHelper$Companion { +} + +public final class com/adobe/marketing/mobile/util/NodeConfig { + public static final field Companion Lcom/adobe/marketing/mobile/util/NodeConfig$Companion; + public fun (Ljava/lang/String;Ljava/util/Map;)V + public fun (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)V + public fun (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Set;)V + public fun (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Set;Lcom/adobe/marketing/mobile/util/NodeConfig;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Set;Lcom/adobe/marketing/mobile/util/NodeConfig;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun createOrUpdateNode (Lcom/adobe/marketing/mobile/util/MultiPathConfig;)V + public final fun createOrUpdateNode (Lcom/adobe/marketing/mobile/util/PathConfig;)V + public final fun deepCopy ()Lcom/adobe/marketing/mobile/util/NodeConfig; + public fun equals (Ljava/lang/Object;)Z + public final fun getAnyOrderMatch ()Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public final fun getArrayPathComponents (Ljava/lang/String;)Lkotlin/Pair; + public final fun getChild (Ljava/lang/Integer;)Lcom/adobe/marketing/mobile/util/NodeConfig; + public final fun getChild (Ljava/lang/String;)Lcom/adobe/marketing/mobile/util/NodeConfig; + public final fun getChildren ()Ljava/util/Set; + public final fun getCollectionEqualCount ()Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public final fun getElementCount ()Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public final fun getKeyMustBeAbsent ()Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public final fun getNextNode (Ljava/lang/Integer;)Lcom/adobe/marketing/mobile/util/NodeConfig; + public final fun getNextNode (Ljava/lang/String;)Lcom/adobe/marketing/mobile/util/NodeConfig; + public final fun getObjectPathComponents (Ljava/lang/String;)Ljava/util/List; + public final fun getPrimitiveExactMatch ()Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public final fun getSingleNodeOption (Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey;)Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public final fun getSubtreeNodeOption (Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey;)Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public final fun getValueNotEqual ()Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public fun hashCode ()I + public static final fun resolveOption (Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey;Ljava/lang/Integer;Lcom/adobe/marketing/mobile/util/NodeConfig;)Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public static final fun resolveOption (Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey;Ljava/lang/String;Lcom/adobe/marketing/mobile/util/NodeConfig;)Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public final fun setAnyOrderMatch (Lcom/adobe/marketing/mobile/util/NodeConfig$Config;)V + public final fun setCollectionEqualCount (Lcom/adobe/marketing/mobile/util/NodeConfig$Config;)V + public final fun setElementCount (Lcom/adobe/marketing/mobile/util/NodeConfig$Config;)V + public final fun setKeyMustBeAbsent (Lcom/adobe/marketing/mobile/util/NodeConfig$Config;)V + public final fun setPrimitiveExactMatch (Lcom/adobe/marketing/mobile/util/NodeConfig$Config;)V + public final fun setValueNotEqual (Lcom/adobe/marketing/mobile/util/NodeConfig$Config;)V +} + +public final class com/adobe/marketing/mobile/util/NodeConfig$Companion { + public final fun resolveOption (Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey;Ljava/lang/Integer;Lcom/adobe/marketing/mobile/util/NodeConfig;)Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public final fun resolveOption (Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey;Ljava/lang/String;Lcom/adobe/marketing/mobile/util/NodeConfig;)Lcom/adobe/marketing/mobile/util/NodeConfig$Config; +} + +public final class com/adobe/marketing/mobile/util/NodeConfig$Config { + public fun (ZLjava/lang/Integer;)V + public synthetic fun (ZLjava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Z + public final fun component2 ()Ljava/lang/Integer; + public final fun copy (ZLjava/lang/Integer;)Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public static synthetic fun copy$default (Lcom/adobe/marketing/mobile/util/NodeConfig$Config;ZLjava/lang/Integer;ILjava/lang/Object;)Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public final fun deepCopy ()Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public fun equals (Ljava/lang/Object;)Z + public final fun getElementCount ()Ljava/lang/Integer; + public fun hashCode ()I + public final fun isActive ()Z + public fun toString ()Ljava/lang/String; +} + +public final class com/adobe/marketing/mobile/util/NodeConfig$OptionKey : java/lang/Enum { + public static final field AnyOrderMatch Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey; + public static final field CollectionEqualCount Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey; + public static final field ElementCount Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey; + public static final field KeyMustBeAbsent Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey; + public static final field PrimitiveExactMatch Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey; + public static final field ValueNotEqual Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey; + public final fun getValue ()Ljava/lang/String; + public static fun valueOf (Ljava/lang/String;)Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey; + public static fun values ()[Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey; +} + +public final class com/adobe/marketing/mobile/util/NodeConfig$Scope : java/lang/Enum { + public static final field SingleNode Lcom/adobe/marketing/mobile/util/NodeConfig$Scope; + public static final field Subtree Lcom/adobe/marketing/mobile/util/NodeConfig$Scope; + public final fun getValue ()Ljava/lang/String; + public static fun valueOf (Ljava/lang/String;)Lcom/adobe/marketing/mobile/util/NodeConfig$Scope; + public static fun values ()[Lcom/adobe/marketing/mobile/util/NodeConfig$Scope; +} + +public final class com/adobe/marketing/mobile/util/PathConfig { + public fun (Lcom/adobe/marketing/mobile/util/NodeConfig$Config;Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/lang/String;Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey;)V + public final fun component1 ()Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public final fun component2 ()Lcom/adobe/marketing/mobile/util/NodeConfig$Scope; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey; + public final fun copy (Lcom/adobe/marketing/mobile/util/NodeConfig$Config;Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/lang/String;Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey;)Lcom/adobe/marketing/mobile/util/PathConfig; + public static synthetic fun copy$default (Lcom/adobe/marketing/mobile/util/PathConfig;Lcom/adobe/marketing/mobile/util/NodeConfig$Config;Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/lang/String;Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey;ILjava/lang/Object;)Lcom/adobe/marketing/mobile/util/PathConfig; + public fun equals (Ljava/lang/Object;)Z + public final fun getConfig ()Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public final fun getOptionKey ()Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey; + public final fun getPath ()Ljava/lang/String; + public final fun getScope ()Lcom/adobe/marketing/mobile/util/NodeConfig$Scope; + public fun hashCode ()I + public final fun setConfig (Lcom/adobe/marketing/mobile/util/NodeConfig$Config;)V + public final fun setOptionKey (Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey;)V + public final fun setPath (Ljava/lang/String;)V + public final fun setScope (Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;)V + public fun toString ()Ljava/lang/String; +} + +public final class com/adobe/marketing/mobile/util/RealNetworkService : com/adobe/marketing/mobile/services/NetworkServiceHelper { + public static final field Companion Lcom/adobe/marketing/mobile/util/RealNetworkService$Companion; + public fun ()V + public final fun assertAllNetworkRequestExpectations (ZZI)V + public static synthetic fun assertAllNetworkRequestExpectations$default (Lcom/adobe/marketing/mobile/util/RealNetworkService;ZZIILjava/lang/Object;)V + public fun connectAsync (Lcom/adobe/marketing/mobile/services/NetworkRequest;Lcom/adobe/marketing/mobile/services/NetworkCallback;)V + public final fun getAllNetworkRequests ()Ljava/util/List; + public final fun getConnectAsyncCallCount ()I + public final fun getConnectAsyncCalled ()Z + public final fun getNetworkRequestsWith (Ljava/lang/String;Lcom/adobe/marketing/mobile/services/HttpMethod;)Ljava/util/List; + public final fun getNetworkRequestsWith (Ljava/lang/String;Lcom/adobe/marketing/mobile/services/HttpMethod;I)Ljava/util/List; + public static synthetic fun getNetworkRequestsWith$default (Lcom/adobe/marketing/mobile/util/RealNetworkService;Ljava/lang/String;Lcom/adobe/marketing/mobile/services/HttpMethod;IILjava/lang/Object;)Ljava/util/List; + public final fun getResponsesFor (Lcom/adobe/marketing/mobile/services/NetworkRequest;)Ljava/util/List; + public final fun reset ()V + public final fun setExpectationForNetworkRequest (Lcom/adobe/marketing/mobile/services/NetworkRequest;I)V + public final fun setExpectationForNetworkRequest (Ljava/lang/String;Lcom/adobe/marketing/mobile/services/HttpMethod;I)V +} + +public final class com/adobe/marketing/mobile/util/RealNetworkService$Companion { +} + +public class com/adobe/marketing/mobile/util/TestConstants { + public static final field EDGE_DATA_STORAGE Ljava/lang/String; + public static final field EVENT_NAME_ERROR_RESPONSE_CONTENT Ljava/lang/String; + public static final field EVENT_NAME_REQUEST_CONTENT Ljava/lang/String; + public static final field EVENT_NAME_RESPONSE_CONTENT Ljava/lang/String; + public static final field EXTENSION_NAME Ljava/lang/String; + public static final field LOG_TAG Ljava/lang/String; + public static final field NETWORK_REQUEST_MAX_RETRIES I +} + +public class com/adobe/marketing/mobile/util/TestConstants$DataStoreKey { + public static final field CONFIG_DATASTORE Ljava/lang/String; + public static final field IDENTITY_DATASTORE Ljava/lang/String; + public static final field IDENTITY_DIRECT_DATASTORE Ljava/lang/String; + public static final field STORE_PAYLOADS Ljava/lang/String; +} + +public class com/adobe/marketing/mobile/util/TestConstants$Defaults { + public static final field EXEDGE_CONSENT_INT_URL_STRING Ljava/lang/String; + public static final field EXEDGE_CONSENT_PRE_PROD_URL_STRING Ljava/lang/String; + public static final field EXEDGE_CONSENT_URL_STRING Ljava/lang/String; + public static final field EXEDGE_INTERACT_INT_URL_STRING Ljava/lang/String; + public static final field EXEDGE_INTERACT_OR2_LOC_URL_STRING Ljava/lang/String; + public static final field EXEDGE_INTERACT_PRE_PROD_URL_STRING Ljava/lang/String; + public static final field EXEDGE_INTERACT_URL_STRING Ljava/lang/String; + public static final field EXEDGE_MEDIA_OR2_LOC_URL_STRING Ljava/lang/String; + public static final field EXEDGE_MEDIA_PROD_URL_STRING Ljava/lang/String; + public static final field WAIT_EVENT_TIMEOUT_MS I + public static final field WAIT_NETWORK_REQUEST_TIMEOUT_MS I + public static final field WAIT_SHARED_STATE_TIMEOUT_MS I + public static final field WAIT_TIMEOUT_MS I +} + +public class com/adobe/marketing/mobile/util/TestConstants$EventDataKey { + public static final field DATASET_ID Ljava/lang/String; + public static final field EDGE_REQUEST_ID Ljava/lang/String; + public static final field REQUEST_EVENT_ID Ljava/lang/String; + public static final field STATE_OWNER Ljava/lang/String; +} + +public class com/adobe/marketing/mobile/util/TestConstants$EventSource { + public static final field ERROR_RESPONSE_CONTENT Ljava/lang/String; + public static final field LOCATION_HINT_RESULT Ljava/lang/String; + public static final field RESPONSE_CONTENT Ljava/lang/String; + public static final field SHARED_STATE_REQUEST Ljava/lang/String; + public static final field SHARED_STATE_RESPONSE Ljava/lang/String; + public static final field STATE_STORE Ljava/lang/String; + public static final field UNREGISTER Ljava/lang/String; + public static final field XDM_SHARED_STATE_REQUEST Ljava/lang/String; + public static final field XDM_SHARED_STATE_RESPONSE Ljava/lang/String; +} + +public class com/adobe/marketing/mobile/util/TestConstants$EventType { + public static final field CONFIGURATION Ljava/lang/String; + public static final field EDGE Ljava/lang/String; + public static final field MONITOR Ljava/lang/String; +} + +public class com/adobe/marketing/mobile/util/TestConstants$NetworkKeys { + public static final field DEFAULT_CONNECT_TIMEOUT_SECONDS I + public static final field DEFAULT_READ_TIMEOUT_SECONDS I + public static final field HEADER_KEY_ACCEPT Ljava/lang/String; + public static final field HEADER_KEY_AEP_VALIDATION_TOKEN Ljava/lang/String; + public static final field HEADER_KEY_CONTENT_TYPE Ljava/lang/String; + public static final field HEADER_VALUE_APPLICATION_JSON Ljava/lang/String; + public static final field REQUEST_HEADER_KEY_REQUEST_ID Ljava/lang/String; + public static final field REQUEST_PARAMETER_KEY_CONFIG_ID Ljava/lang/String; + public static final field REQUEST_PARAMETER_KEY_REQUEST_ID Ljava/lang/String; + public static final field REQUEST_URL Ljava/lang/String; +} + +public class com/adobe/marketing/mobile/util/TestConstants$SharedState { + public static final field ASSURANCE Ljava/lang/String; + public static final field CONFIGURATION Ljava/lang/String; + public static final field CONSENT Ljava/lang/String; + public static final field EDGE Ljava/lang/String; + public static final field IDENTITY Ljava/lang/String; + public static final field LIFECYCLE Ljava/lang/String; + public static final field STATE_OWNER Ljava/lang/String; +} + +public class com/adobe/marketing/mobile/util/TestHelper { + public fun ()V + public static fun assertExpectedEvents (Z)V + public static fun assertUnexpectedEvents ()V + public static fun assertUnexpectedEvents (Z)V + public static fun getAsset (Ljava/lang/String;)Ljava/io/InputStream; + public static fun getDispatchedEventsWith (Ljava/lang/String;Ljava/lang/String;)Ljava/util/List; + public static fun getDispatchedEventsWith (Ljava/lang/String;Ljava/lang/String;I)Ljava/util/List; + public static fun getSharedStateFor (Ljava/lang/String;I)Ljava/util/Map; + public static fun getXDMSharedStateFor (Ljava/lang/String;I)Ljava/util/Map; + public static fun registerExtensions (Ljava/util/List;Ljava/util/Map;)V + public static fun resetCoreHelper ()V + public static fun resetTestExpectations ()V + public static fun setExpectationEvent (Ljava/lang/String;Ljava/lang/String;I)V + public static fun sleep (I)V + public static fun waitForThreads (I)V +} + +public class com/adobe/marketing/mobile/util/TestHelper$CustomApplication : android/app/Application { + public fun ()V +} + +public class com/adobe/marketing/mobile/util/TestHelper$LogOnErrorRule : org/junit/rules/TestRule { + public fun ()V + public fun apply (Lorg/junit/runners/model/Statement;Lorg/junit/runner/Description;)Lorg/junit/runners/model/Statement; +} + +public class com/adobe/marketing/mobile/util/TestHelper$SetupCoreRule : org/junit/rules/TestRule { + public fun ()V + public fun apply (Lorg/junit/runners/model/Statement;Lorg/junit/runner/Description;)Lorg/junit/runners/model/Statement; +} + +public class com/adobe/marketing/mobile/util/TestPersistenceHelper { + public fun ()V + public static fun readPersistedData (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; + public static fun resetKnownPersistence ()V + public static fun updatePersistence (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V +} + +public class com/adobe/marketing/mobile/util/TestUtils { + public static fun flattenBytes ([B)Ljava/util/Map; + public static fun flattenMap (Ljava/util/Map;)Ljava/util/Map; +} + +public final class com/adobe/marketing/mobile/util/ValueExactMatch : com/adobe/marketing/mobile/util/MultiPathConfig { + public static final field Companion Lcom/adobe/marketing/mobile/util/ValueExactMatch$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 (Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;)V + public synthetic fun (Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;[Ljava/lang/String;)V + public synthetic fun (Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;[Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/util/List;)V + public fun ([Ljava/lang/String;)V + public final fun component1 ()Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public final fun component2 ()Lcom/adobe/marketing/mobile/util/NodeConfig$Scope; + public final fun component3 ()Ljava/util/List; + public final fun component4 ()Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey; + public final fun copy (Lcom/adobe/marketing/mobile/util/NodeConfig$Config;Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey;)Lcom/adobe/marketing/mobile/util/ValueExactMatch; + public static synthetic fun copy$default (Lcom/adobe/marketing/mobile/util/ValueExactMatch;Lcom/adobe/marketing/mobile/util/NodeConfig$Config;Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey;ILjava/lang/Object;)Lcom/adobe/marketing/mobile/util/ValueExactMatch; + public fun equals (Ljava/lang/Object;)Z + public fun getConfig ()Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public fun getOptionKey ()Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey; + public fun getPaths ()Ljava/util/List; + public fun getScope ()Lcom/adobe/marketing/mobile/util/NodeConfig$Scope; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/adobe/marketing/mobile/util/ValueExactMatch$Companion { +} + +public final class com/adobe/marketing/mobile/util/ValueNotEqual : com/adobe/marketing/mobile/util/MultiPathConfig { + public static final field Companion Lcom/adobe/marketing/mobile/util/ValueNotEqual$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 (Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;)V + public fun (Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;[Ljava/lang/String;)V + public fun (Ljava/util/List;)V + public fun (Z)V + public fun (ZLcom/adobe/marketing/mobile/util/NodeConfig$Scope;)V + public fun (ZLcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;)V + public synthetic fun (ZLcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZLcom/adobe/marketing/mobile/util/NodeConfig$Scope;[Ljava/lang/String;)V + public synthetic fun (ZLcom/adobe/marketing/mobile/util/NodeConfig$Scope;[Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZLjava/util/List;)V + public fun (Z[Ljava/lang/String;)V + public fun ([Ljava/lang/String;)V + public final fun component1 ()Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public final fun component2 ()Lcom/adobe/marketing/mobile/util/NodeConfig$Scope; + public final fun component3 ()Ljava/util/List; + public final fun component4 ()Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey; + public final fun copy (Lcom/adobe/marketing/mobile/util/NodeConfig$Config;Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey;)Lcom/adobe/marketing/mobile/util/ValueNotEqual; + public static synthetic fun copy$default (Lcom/adobe/marketing/mobile/util/ValueNotEqual;Lcom/adobe/marketing/mobile/util/NodeConfig$Config;Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey;ILjava/lang/Object;)Lcom/adobe/marketing/mobile/util/ValueNotEqual; + public fun equals (Ljava/lang/Object;)Z + public fun getConfig ()Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public fun getOptionKey ()Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey; + public fun getPaths ()Ljava/util/List; + public fun getScope ()Lcom/adobe/marketing/mobile/util/NodeConfig$Scope; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/adobe/marketing/mobile/util/ValueNotEqual$Companion { +} + +public final class com/adobe/marketing/mobile/util/ValueTypeMatch : com/adobe/marketing/mobile/util/MultiPathConfig { + public static final field Companion Lcom/adobe/marketing/mobile/util/ValueTypeMatch$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 (Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;)V + public synthetic fun (Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;[Ljava/lang/String;)V + public synthetic fun (Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;[Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/util/List;)V + public fun ([Ljava/lang/String;)V + public final fun component1 ()Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public final fun component2 ()Lcom/adobe/marketing/mobile/util/NodeConfig$Scope; + public final fun component3 ()Ljava/util/List; + public final fun component4 ()Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey; + public final fun copy (Lcom/adobe/marketing/mobile/util/NodeConfig$Config;Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey;)Lcom/adobe/marketing/mobile/util/ValueTypeMatch; + public static synthetic fun copy$default (Lcom/adobe/marketing/mobile/util/ValueTypeMatch;Lcom/adobe/marketing/mobile/util/NodeConfig$Config;Lcom/adobe/marketing/mobile/util/NodeConfig$Scope;Ljava/util/List;Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey;ILjava/lang/Object;)Lcom/adobe/marketing/mobile/util/ValueTypeMatch; + public fun equals (Ljava/lang/Object;)Z + public fun getConfig ()Lcom/adobe/marketing/mobile/util/NodeConfig$Config; + public fun getOptionKey ()Lcom/adobe/marketing/mobile/util/NodeConfig$OptionKey; + public fun getPaths ()Ljava/util/List; + public fun getScope ()Lcom/adobe/marketing/mobile/util/NodeConfig$Scope; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/adobe/marketing/mobile/util/ValueTypeMatch$Companion { +} + From f3255b948985fc29471a127b44df72dde9939813 Mon Sep 17 00:00:00 2001 From: timkimadobe <95260439+timkimadobe@users.noreply.github.com> Date: Thu, 7 Nov 2024 17:56:55 -0800 Subject: [PATCH 3/7] Add AEPTestUtils rules --- Makefile | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 32f6d300d..67b4f18c8 100644 --- a/Makefile +++ b/Makefile @@ -2,25 +2,27 @@ clean: (./code/gradlew -p code clean) -checkstyle: core-checkstyle signal-checkstyle lifecycle-checkstyle identity-checkstyle +checkstyle: core-checkstyle signal-checkstyle lifecycle-checkstyle identity-checkstyle testutils-checkstyle -checkformat: core-checkformat signal-checkformat lifecycle-checkformat identity-checkformat +checkformat: core-checkformat signal-checkformat lifecycle-checkformat identity-checkformat testutils-checkformat -format: core-format signal-format lifecycle-format identity-format +format: core-format signal-format lifecycle-format identity-format testutils-format api-dump: (./code/gradlew -p code/core apiDump) + (./code/gradlew -p code/testutils apiDump) api-check: (./code/gradlew -p code/core apiCheck) + (./code/gradlew -p code/testutils apiCheck) assemble-phone: core-assemble-phone signal-assemble-phone lifecycle-assemble-phone identity-assemble-phone assemble-phone-release: core-assemble-phone-release signal-assemble-phone-release lifecycle-assemble-phone-release identity-assemble-phone-release -unit-test: core-unit-test signal-unit-test lifecycle-unit-test +unit-test: core-unit-test signal-unit-test lifecycle-unit-test testutils-unit-test -unit-test-coverage: core-unit-test-coverage signal-unit-test-coverage lifecycle-unit-test-coverage +unit-test-coverage: core-unit-test-coverage signal-unit-test-coverage lifecycle-unit-test-coverage testutils-unit-test-coverage functional-test: core-functional-test signal-functional-test lifecycle-functional-test identity-functional-test @@ -210,3 +212,24 @@ identity-publish-maven-local: identity-publish-maven-local-jitpack: (./code/gradlew -p code/identity assemblePhone) (./code/gradlew -p code/identity publishReleasePublicationToMavenLocal -Pjitpack -x signReleasePublication) + +### TestUtils + +testutils-checkstyle: + (./code/gradlew -p code/testutils checkstyle) + +testutils-checkformat: + (./code/gradlew -p code/testutils spotlessCheck) + +testutils-format: + (./code/gradlew -p code/testutils spotlessApply) + +testutils-unit-test: + (./code/gradlew -p code/testutils testPhoneDebugUnitTest) + +testutils-unit-test-coverage: + (./code/gradlew -p code/testutils createPhoneDebugUnitTestCoverageReport) + +testutils-publish-maven-local-jitpack: + (./code/gradlew -p code/testutils assemblePhone) + (./code/gradlew -p code/testutils publishReleasePublicationToMavenLocal -Pjitpack -x signReleasePublication) \ No newline at end of file From 84246ee1319557b85a9e2492585a3d4b66cc3e5c Mon Sep 17 00:00:00 2001 From: timkimadobe <95260439+timkimadobe@users.noreply.github.com> Date: Thu, 7 Nov 2024 18:04:07 -0800 Subject: [PATCH 4/7] Remove check style for AEPTestUtils --- Makefile | 5 +---- code/testutils/build.gradle.kts | 1 - 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 67b4f18c8..7064dd02c 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ clean: (./code/gradlew -p code clean) -checkstyle: core-checkstyle signal-checkstyle lifecycle-checkstyle identity-checkstyle testutils-checkstyle +checkstyle: core-checkstyle signal-checkstyle lifecycle-checkstyle identity-checkstyle checkformat: core-checkformat signal-checkformat lifecycle-checkformat identity-checkformat testutils-checkformat @@ -215,9 +215,6 @@ identity-publish-maven-local-jitpack: ### TestUtils -testutils-checkstyle: - (./code/gradlew -p code/testutils checkstyle) - testutils-checkformat: (./code/gradlew -p code/testutils spotlessCheck) diff --git a/code/testutils/build.gradle.kts b/code/testutils/build.gradle.kts index a5cbafa1a..90623eb75 100644 --- a/code/testutils/build.gradle.kts +++ b/code/testutils/build.gradle.kts @@ -25,7 +25,6 @@ aepLibrary { moduleName = "testutils" moduleVersion = "3.0.0" enableSpotless = true - enableCheckStyle = true enableSpotlessPrettierForJava = true enableDokkaDoc = true From a3c5d7ec8b1bda71f1dfa5449aa0b4d554196115 Mon Sep 17 00:00:00 2001 From: timkimadobe <95260439+timkimadobe@users.noreply.github.com> Date: Thu, 7 Nov 2024 18:16:03 -0800 Subject: [PATCH 5/7] Update Core gradle config to make AEPTestUtils available --- code/core/build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/code/core/build.gradle.kts b/code/core/build.gradle.kts index 9adefb9cc..ba01c7a5d 100644 --- a/code/core/build.gradle.kts +++ b/code/core/build.gradle.kts @@ -52,5 +52,7 @@ dependencies { androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0") //TODO: Consider moving this to the aep-library plugin later androidTestImplementation("com.linkedin.dexmaker:dexmaker-mockito-inline:2.28.3") + androidTestImplementation(project(":testutils")) testImplementation("org.robolectric:robolectric:4.7") + testImplementation(project(":testutils")) } From 1aa33c8a8dfb6597e813bd2540562d3a1ad46ff2 Mon Sep 17 00:00:00 2001 From: timkimadobe <95260439+timkimadobe@users.noreply.github.com> Date: Thu, 7 Nov 2024 18:39:15 -0800 Subject: [PATCH 6/7] Update unit test runner to node version for AEPTestUtils java prettier format --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index b4e60488a..24efe5e1d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -9,7 +9,7 @@ jobs: executor: name: android/android-docker resource-class: large - tag: 2024.01.1 + tag: 2024.01.1-node steps: - checkout From 66effd38a34b0bc5a40e419493a2f6992e7381d2 Mon Sep 17 00:00:00 2001 From: timkimadobe <95260439+timkimadobe@users.noreply.github.com> Date: Fri, 8 Nov 2024 13:29:24 -0800 Subject: [PATCH 7/7] Update to include testutils jitpack step --- jitpack.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/jitpack.yml b/jitpack.yml index c1083df81..56c07a185 100644 --- a/jitpack.yml +++ b/jitpack.yml @@ -6,3 +6,4 @@ install: - make signal-publish-maven-local-jitpack - make lifecycle-publish-maven-local-jitpack - make identity-publish-maven-local-jitpack + - make testutils-publish-maven-local-jitpack