Skip to content

Commit

Permalink
Add SdkComponent and move configuration fetch out of BraintreeClient (#…
Browse files Browse the repository at this point in the history
…1203)

* Move getting configuration out of BraintreeClient and move dependencies that require context to a SdkComponent singleton

* Update unit tests

* Add latency analytics event to configuration call

* Add a TODO for removing the circular dependency between AnalyticsClient and ConfigurationLoader
tdchow authored Nov 14, 2024
1 parent a70a4df commit c9d4246
Showing 13 changed files with 325 additions and 200 deletions.
Original file line number Diff line number Diff line change
@@ -15,28 +15,29 @@ import java.util.concurrent.TimeUnit

@Suppress("SwallowedException", "TooGenericExceptionCaught")
internal class AnalyticsClient(
context: Context,
private val httpClient: BraintreeHttpClient = BraintreeHttpClient(),
private val analyticsDatabase: AnalyticsDatabase = AnalyticsDatabase.getInstance(context.applicationContext),
private val workManager: WorkManager = WorkManager.getInstance(context.applicationContext),
private val analyticsDatabase: AnalyticsDatabase = AnalyticsDatabaseProvider().analyticsDatabase,
private val workManager: WorkManager = WorkManagerProvider().workManager,
private val deviceInspector: DeviceInspector = DeviceInspector(),
private val analyticsParamRepository: AnalyticsParamRepository = AnalyticsParamRepository.instance,
private val time: Time = Time()
private val time: Time = Time(),
private val configurationLoader: ConfigurationLoader = ConfigurationLoader.instance,
private val merchantRepository: MerchantRepository = MerchantRepository.instance
) {
private val applicationContext = context.applicationContext
private val applicationContext: Context
get() = merchantRepository.applicationContext

fun sendEvent(
configuration: Configuration,
event: AnalyticsEvent,
integration: IntegrationType?,
authorization: Authorization
): UUID {
scheduleAnalyticsWriteInBackground(event, authorization)
return scheduleAnalyticsUploadInBackground(
configuration,
authorization,
integration
)
fun sendEvent(event: AnalyticsEvent) {
configurationLoader.loadConfiguration { result ->
if (result is ConfigurationLoaderResult.Success) {
scheduleAnalyticsWriteInBackground(event, merchantRepository.authorization)
scheduleAnalyticsUploadInBackground(
configuration = result.configuration,
authorization = merchantRepository.authorization,
integration = merchantRepository.integrationType
)
}
}
}

private fun scheduleAnalyticsWriteInBackground(
Original file line number Diff line number Diff line change
@@ -16,7 +16,7 @@ internal class AnalyticsUploadWorker(
) : Worker(context, params) {

override fun doWork(): Result {
val analyticsClient = AnalyticsClient(applicationContext)
val analyticsClient = AnalyticsClient()
return analyticsClient.performAnalyticsUpload(inputData)
}
}
Original file line number Diff line number Diff line change
@@ -16,7 +16,7 @@ internal class AnalyticsWriteToDbWorker(
) : Worker(context, params) {

override fun doWork(): Result {
val analyticsClient = AnalyticsClient(applicationContext)
val analyticsClient = AnalyticsClient()
return analyticsClient.performAnalyticsWrite(inputData)
}
}
Original file line number Diff line number Diff line change
@@ -22,13 +22,14 @@ class BraintreeClient internal constructor(
authorization: Authorization,
returnUrlScheme: String,
appLinkReturnUri: Uri?,
private val analyticsClient: AnalyticsClient = AnalyticsClient(applicationContext),
sdkComponent: SdkComponent = SdkComponent.create(applicationContext),
private val httpClient: BraintreeHttpClient = BraintreeHttpClient(),
private val graphQLClient: BraintreeGraphQLClient = BraintreeGraphQLClient(),
private val configurationLoader: ConfigurationLoader = ConfigurationLoader(applicationContext, httpClient),
private val configurationLoader: ConfigurationLoader = ConfigurationLoader.instance,
private val manifestValidator: ManifestValidator = ManifestValidator(),
private val time: Time = Time(),
private val merchantRepository: MerchantRepository = MerchantRepository.instance
private val merchantRepository: MerchantRepository = MerchantRepository.instance,
private val analyticsClient: AnalyticsClient = AnalyticsClient(),
) {

private val crashReporter: CrashReporter
@@ -81,17 +82,15 @@ class BraintreeClient internal constructor(
* @param callback [ConfigurationCallback]
*/
fun getConfiguration(callback: ConfigurationCallback) {
if (merchantRepository.authorization is InvalidAuthorization) {
callback.onResult(null, createAuthError())
return
}
configurationLoader.loadConfiguration(merchantRepository.authorization) { configuration, configError, timing ->
if (configuration != null) {
callback.onResult(configuration, null)
} else {
callback.onResult(null, configError)
configurationLoader.loadConfiguration { result ->
when (result) {
is ConfigurationLoaderResult.Success -> {
callback.onResult(result.configuration, null)
result.timing?.let { sendAnalyticsTimingEvent("/v1/configuration", it) }
}

is ConfigurationLoaderResult.Failure -> callback.onResult(null, result.error)
}
timing?.let { sendAnalyticsTimingEvent("/v1/configuration", it) }
}
}

@@ -102,47 +101,25 @@ class BraintreeClient internal constructor(
eventName: String,
params: AnalyticsEventParams = AnalyticsEventParams()
) {
val timestamp = time.currentTime
getConfiguration { configuration, _ ->
val event = AnalyticsEvent(
name = eventName,
timestamp = timestamp,
payPalContextId = params.payPalContextId,
linkType = params.linkType,
isVaultRequest = params.isVaultRequest,
startTime = params.startTime,
endTime = params.endTime,
endpoint = params.endpoint,
experiment = params.experiment,
paymentMethodsDisplayed = params.paymentMethodsDisplayed
)
sendAnalyticsEvent(event, configuration, merchantRepository.authorization)
}
}

private fun sendAnalyticsEvent(
event: AnalyticsEvent,
configuration: Configuration?,
authorization: Authorization
) {
configuration?.let {
analyticsClient.sendEvent(
it,
event,
merchantRepository.integrationType,
authorization
)
}
val event = AnalyticsEvent(
name = eventName,
timestamp = time.currentTime,
payPalContextId = params.payPalContextId,
linkType = params.linkType,
isVaultRequest = params.isVaultRequest,
startTime = params.startTime,
endTime = params.endTime,
endpoint = params.endpoint,
experiment = params.experiment,
paymentMethodsDisplayed = params.paymentMethodsDisplayed
)
analyticsClient.sendEvent(event)
}

/**
* @suppress
*/
fun sendGET(url: String, responseCallback: HttpResponseCallback) {
if (merchantRepository.authorization is InvalidAuthorization) {
responseCallback.onResult(null, createAuthError())
return
}
getConfiguration { configuration, configError ->
if (configuration != null) {
httpClient.get(url, configuration, merchantRepository.authorization) { response, httpError ->
@@ -173,10 +150,6 @@ class BraintreeClient internal constructor(
additionalHeaders: Map<String, String> = emptyMap(),
responseCallback: HttpResponseCallback,
) {
if (merchantRepository.authorization is InvalidAuthorization) {
responseCallback.onResult(null, createAuthError())
return
}
getConfiguration { configuration, configError ->
if (configuration != null) {
httpClient.post(
@@ -207,10 +180,6 @@ class BraintreeClient internal constructor(
* @suppress
*/
fun sendGraphQLPOST(json: JSONObject?, responseCallback: HttpResponseCallback) {
if (merchantRepository.authorization is InvalidAuthorization) {
responseCallback.onResult(null, createAuthError())
return
}
getConfiguration { configuration, configError ->
if (configuration != null) {
graphQLClient.post(
@@ -230,7 +199,7 @@ class BraintreeClient internal constructor(
endpoint = finalQuery
)
sendAnalyticsEvent(
CoreAnalytics.apiRequestLatency,
CoreAnalytics.API_REQUEST_LATENCY,
params
)
}
@@ -302,7 +271,7 @@ class BraintreeClient internal constructor(
)

sendAnalyticsEvent(
CoreAnalytics.apiRequestLatency,
CoreAnalytics.API_REQUEST_LATENCY,
AnalyticsEventParams(
startTime = timing.startTime,
endTime = timing.endTime,
@@ -328,13 +297,6 @@ class BraintreeClient internal constructor(
this.launchesBrowserSwitchAsNewTask = launchesBrowserSwitchAsNewTask
}

private fun createAuthError(): BraintreeException {
val clientSDKSetupURL =
"https://developer.paypal.com/braintree/docs/guides/client-sdk/setup/android/v4#initialization"
val message = "Valid authorization required. See $clientSDKSetupURL for more info."
return BraintreeException(message)
}

companion object {
private fun getAppPackageNameWithoutUnderscores(context: Context): String {
return context.applicationContext.packageName.replace("_", "")
Original file line number Diff line number Diff line change
@@ -1,24 +1,33 @@
package com.braintreepayments.api.core

import android.content.Context
import android.net.Uri
import android.util.Base64
import com.braintreepayments.api.sharedutils.HttpClient
import com.braintreepayments.api.sharedutils.Time
import org.json.JSONException

internal class ConfigurationLoader(
private val httpClient: BraintreeHttpClient,
private val configurationCache: ConfigurationCache
private val httpClient: BraintreeHttpClient = BraintreeHttpClient(),
private val merchantRepository: MerchantRepository = MerchantRepository.instance,
private val configurationCache: ConfigurationCache = ConfigurationCacheProvider().configurationCache,
private val time: Time = Time(),
/**
* TODO: AnalyticsClient must be lazy due to the circular dependency between ConfigurationLoader and AnalyticsClient
* This should be refactored to remove the circular dependency.
*/
lazyAnalyticsClient: Lazy<AnalyticsClient> = lazy { AnalyticsClient(httpClient) },
) {
constructor(context: Context, httpClient: BraintreeHttpClient) : this(
httpClient, ConfigurationCache.getInstance(context)
)
private val analyticsClient: AnalyticsClient by lazyAnalyticsClient

fun loadConfiguration(authorization: Authorization, callback: ConfigurationLoaderCallback) {
fun loadConfiguration(callback: ConfigurationLoaderCallback) {
val authorization = merchantRepository.authorization
if (authorization is InvalidAuthorization) {
val message = authorization.errorMessage
val clientSDKSetupURL =
"https://developer.paypal.com/braintree/docs/guides/client-sdk/setup/android/v4#initialization"
val message = "Valid authorization required. See $clientSDKSetupURL for more info."

// NOTE: timing information is null when configuration comes from cache
callback.onResult(null, BraintreeException(message), null)
callback.onResult(ConfigurationLoaderResult.Failure(BraintreeException(message)))
return
}
val configUrl = Uri.parse(authorization.configUrl)
@@ -29,7 +38,7 @@ internal class ConfigurationLoader(
val cachedConfig = getCachedConfiguration(authorization, configUrl)

cachedConfig?.let {
callback.onResult(cachedConfig, null, null)
callback.onResult(ConfigurationLoaderResult.Success(it))
} ?: run {
httpClient.get(
configUrl, null, authorization, HttpClient.RETRY_MAX_3_TIMES
@@ -40,16 +49,26 @@ internal class ConfigurationLoader(
try {
val configuration = Configuration.fromJson(responseBody)
saveConfigurationToCache(configuration, authorization, configUrl)
callback.onResult(configuration, null, timing)
callback.onResult(ConfigurationLoaderResult.Success(configuration, timing))

analyticsClient.sendEvent(
AnalyticsEvent(
name = CoreAnalytics.API_REQUEST_LATENCY,
timestamp = time.currentTime,
startTime = timing?.startTime,
endTime = timing?.endTime,
endpoint = "/v1/configuration"
)
)
} catch (jsonException: JSONException) {
callback.onResult(null, jsonException, null)
callback.onResult(ConfigurationLoaderResult.Failure(jsonException))
}
} else {
httpError?.let { error ->
val errorMessageFormat = "Request for configuration has failed: %s"
val errorMessage = String.format(errorMessageFormat, error.message)
val configurationException = ConfigurationException(errorMessage, error)
callback.onResult(null, configurationException, null)
callback.onResult(ConfigurationLoaderResult.Failure(configurationException))
}
}
}
@@ -82,5 +101,10 @@ internal class ConfigurationLoader(
private fun createCacheKey(authorization: Authorization, configUrl: String): String {
return Base64.encodeToString("$configUrl${authorization.bearer}".toByteArray(), 0)
}

/**
* Singleton instance of the ConfigurationLoader.
*/
val instance: ConfigurationLoader by lazy { ConfigurationLoader() }
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package com.braintreepayments.api.core

import com.braintreepayments.api.sharedutils.HttpResponseTiming

internal fun interface ConfigurationLoaderCallback {
fun onResult(result: Configuration?, error: Exception?, timing: HttpResponseTiming?)
fun onResult(result: ConfigurationLoaderResult)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.braintreepayments.api.core

import com.braintreepayments.api.sharedutils.HttpResponseTiming

/**
* Result of calling [ConfigurationLoader.loadConfiguration]
*/
internal sealed class ConfigurationLoaderResult {

data class Success(
val configuration: Configuration,
val timing: HttpResponseTiming? = null
) : ConfigurationLoaderResult()

data class Failure(val error: Exception) : ConfigurationLoaderResult()
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
package com.braintreepayments.api.core

internal object CoreAnalytics {
const val apiRequestLatency = "core:api-request-latency"
const val API_REQUEST_LATENCY = "core:api-request-latency"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.braintreepayments.api.core

import android.content.Context
import androidx.work.WorkManager

/**
* Component class that is created when the BT SDK is launched. It contains dependencies that need to be injected that
* contain Context.
*/
internal class SdkComponent(
applicationContext: Context,
) {
val analyticsDatabase: AnalyticsDatabase = AnalyticsDatabase.getInstance(applicationContext)
val workManager: WorkManager = WorkManager.getInstance(applicationContext)
val configurationCache: ConfigurationCache = ConfigurationCache.getInstance(applicationContext)

companion object {
private var instance: SdkComponent? = null

/**
* Creates and returns a new instance of [SdkComponent], or returns the existing instance.
*/
fun create(applicationContext: Context): SdkComponent {
return instance ?: SdkComponent(applicationContext).also { sdkComponent ->
instance = sdkComponent
}
}

/**
* Returns the instance of [SdkComponent]
*/
fun getInstance(): SdkComponent {
return checkNotNull(instance)
}
}
}

internal class AnalyticsDatabaseProvider {
val analyticsDatabase: AnalyticsDatabase
get() = SdkComponent.getInstance().analyticsDatabase
}

internal class WorkManagerProvider {
val workManager: WorkManager
get() = SdkComponent.getInstance().workManager
}

internal class ConfigurationCacheProvider {
val configurationCache: ConfigurationCache
get() = SdkComponent.getInstance().configurationCache
}
Original file line number Diff line number Diff line change
@@ -40,6 +40,9 @@ class AnalyticsClientUnitTest {
private lateinit var workManager: WorkManager
private lateinit var analyticsDatabase: AnalyticsDatabase
private lateinit var analyticsEventBlobDao: AnalyticsEventBlobDao
private val merchantRepository: MerchantRepository = mockk(relaxed = true)

private lateinit var configurationLoader: ConfigurationLoader

private lateinit var sut: AnalyticsClient

@@ -67,17 +70,24 @@ class AnalyticsClientUnitTest {

every { analyticsDatabase.analyticsEventBlobDao() } returns analyticsEventBlobDao
every { analyticsParamRepository.sessionId } returns sessionId

every { analyticsParamRepository.sessionId } returns sessionId
every { time.currentTime } returns 123
every { merchantRepository.authorization } returns authorization
every { merchantRepository.applicationContext } returns context

configurationLoader = MockkConfigurationLoaderBuilder()
.configuration(configuration)
.build()

sut = AnalyticsClient(
context = context,
httpClient = httpClient,
analyticsDatabase = analyticsDatabase,
workManager = workManager,
deviceInspector = deviceInspector,
analyticsParamRepository = analyticsParamRepository,
time = time
time = time,
configurationLoader = configurationLoader,
merchantRepository = merchantRepository
)
}

@@ -95,7 +105,7 @@ class AnalyticsClientUnitTest {

val event = AnalyticsEvent(eventName, timestamp = 123)

sut.sendEvent(configuration, event, integration, authorization)
sut.sendEvent(event)

val workSpec = workRequestSlot.captured.workSpec
assertEquals(AnalyticsWriteToDbWorker::class.java.name, workSpec.workerClassName)
@@ -132,7 +142,7 @@ class AnalyticsClientUnitTest {
timestamp = 456
)

sut.sendEvent(configuration, event, integration, authorization)
sut.sendEvent(event)

val workSpec = workRequestSlot.captured.workSpec
assertEquals(30000, workSpec.initialDelay)
@@ -203,8 +213,14 @@ class AnalyticsClientUnitTest {
.putString(AnalyticsClient.WORK_INPUT_KEY_SESSION_ID, sessionId)
.putString(AnalyticsClient.WORK_INPUT_KEY_INTEGRATION, integration.stringValue)
.build()
val sut =
AnalyticsClient(context, httpClient, analyticsDatabase, workManager, deviceInspector)
val sut = AnalyticsClient(
httpClient = httpClient,
analyticsDatabase = analyticsDatabase,
workManager = workManager,
deviceInspector = deviceInspector,
configurationLoader = configurationLoader,
merchantRepository = merchantRepository
)
sut.performAnalyticsUpload(inputData)

// or confirmVerified(httpClient)
@@ -513,7 +529,7 @@ class AnalyticsClientUnitTest {
} returns metadata

val event = AnalyticsEvent(eventName, timestamp)
sut.sendEvent(configuration, event, integration, authorization)
sut.sendEvent(event)

sut.reportCrash(context, configuration, integration, null)

@@ -523,7 +539,7 @@ class AnalyticsClientUnitTest {

@Test
fun `sendEvent enqueues work to upload analytic events with sessionId in the name`() {
sut.sendEvent(configuration, AnalyticsEvent("event-name", timestamp), integration, authorization)
sut.sendEvent(AnalyticsEvent("event-name", timestamp))

verify {
workManager.enqueueUniqueWork(
Original file line number Diff line number Diff line change
@@ -327,26 +327,11 @@ class BraintreeClientUnitTest {

verify {
analyticsClient.sendEvent(
configuration,
match { it.name == "event.started" && it.timestamp == 123L },
IntegrationType.CUSTOM,
authorization
)
}
}

@Test
fun sendAnalyticsEvent_whenConfigurationLoadFails_doesNothing() {
val configurationLoader = MockkConfigurationLoaderBuilder()
.configurationError(Exception("error"))
.build()

val sut = createBraintreeClient(configurationLoader)
sut.sendAnalyticsEvent("event.started")

verify { analyticsClient wasNot Called }
}

@Test
fun isUrlSchemeDeclaredInAndroidManifest_forwardsInvocationToManifestValidator() {
every {
@@ -416,10 +401,10 @@ class BraintreeClientUnitTest {

val callbackSlot = slot<ConfigurationLoaderCallback>()
verify {
configurationLoader.loadConfiguration(authorization, capture(callbackSlot))
configurationLoader.loadConfiguration(capture(callbackSlot))
}

callbackSlot.captured.onResult(configuration, null, null)
callbackSlot.captured.onResult(ConfigurationLoaderResult.Success(configuration))

verify {
analyticsClient.reportCrash(
Original file line number Diff line number Diff line change
@@ -1,43 +1,57 @@
package com.braintreepayments.api.core

import android.util.Base64
import com.braintreepayments.api.testutils.Fixtures
import com.braintreepayments.api.sharedutils.HttpClient
import com.braintreepayments.api.sharedutils.HttpResponse
import com.braintreepayments.api.sharedutils.HttpResponseTiming
import com.braintreepayments.api.sharedutils.NetworkResponseCallback
import io.mockk.*
import org.robolectric.RobolectricTestRunner
import com.braintreepayments.api.sharedutils.Time
import com.braintreepayments.api.testutils.Fixtures
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import org.json.JSONException
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.lang.Exception
import org.robolectric.RobolectricTestRunner
import kotlin.test.assertEquals
import kotlin.test.assertTrue

@RunWith(RobolectricTestRunner::class)
class ConfigurationLoaderUnitTest {
private var configurationCache: ConfigurationCache = mockk(relaxed = true)
private var braintreeHttpClient: BraintreeHttpClient = mockk(relaxed = true)
private var callback: ConfigurationLoaderCallback = mockk(relaxed = true)
private var authorization: Authorization = mockk(relaxed = true)
private val configurationCache: ConfigurationCache = mockk(relaxed = true)
private val braintreeHttpClient: BraintreeHttpClient = mockk(relaxed = true)
private val callback: ConfigurationLoaderCallback = mockk(relaxed = true)
private val authorization: Authorization = mockk(relaxed = true)
private val merchantRepository: MerchantRepository = mockk(relaxed = true)
private val analyticsClient: AnalyticsClient = mockk(relaxed = true)
private val time: Time = mockk(relaxed = true)

@Before
fun setUp() {
every { merchantRepository.authorization } returns authorization
}

@Test
fun loadConfiguration_loadsConfigurationForTheCurrentEnvironment() {

every { authorization.configUrl } returns "https://example.com/config"
every { merchantRepository.authorization } returns authorization

val sut = ConfigurationLoader(braintreeHttpClient, configurationCache)
sut.loadConfiguration(authorization, callback)
val sut = ConfigurationLoader(braintreeHttpClient, merchantRepository, configurationCache)
sut.loadConfiguration(callback)

val expectedConfigUrl = "https://example.com/config?configVersion=3"
val callbackSlot = slot<NetworkResponseCallback>()
verify {
braintreeHttpClient.get(
expectedConfigUrl,
null,
authorization,
HttpClient.RETRY_MAX_3_TIMES,
capture(callbackSlot)
expectedConfigUrl,
null,
authorization,
HttpClient.RETRY_MAX_3_TIMES,
capture(callbackSlot)
)
}

@@ -46,26 +60,29 @@ class ConfigurationLoaderUnitTest {
HttpResponse(Fixtures.CONFIGURATION_WITH_ACCESS_TOKEN, HttpResponseTiming(0, 0)), null
)

verify { callback.onResult(ofType(Configuration::class), null, HttpResponseTiming(0, 0)) }
val successSlot = slot<ConfigurationLoaderResult>()
verify { callback.onResult(capture(successSlot)) }

assertTrue { successSlot.captured is ConfigurationLoaderResult.Success }
}

@Test
fun loadConfiguration_savesFetchedConfigurationToCache() {
every { authorization.configUrl } returns "https://example.com/config"
every { authorization.bearer } returns "bearer"

val sut = ConfigurationLoader(braintreeHttpClient, configurationCache)
sut.loadConfiguration(authorization, callback)
val sut = ConfigurationLoader(braintreeHttpClient, merchantRepository, configurationCache)
sut.loadConfiguration(callback)

val expectedConfigUrl = "https://example.com/config?configVersion=3"
val callbackSlot = slot<NetworkResponseCallback>()
verify {
braintreeHttpClient.get(
expectedConfigUrl,
null,
authorization,
HttpClient.RETRY_MAX_3_TIMES,
capture(callbackSlot)
expectedConfigUrl,
null,
authorization,
HttpClient.RETRY_MAX_3_TIMES,
capture(callbackSlot)
)
}

@@ -86,71 +103,72 @@ class ConfigurationLoaderUnitTest {
@Test
fun loadConfiguration_onJSONParsingError_forwardsExceptionToErrorResponseListener() {
every { authorization.configUrl } returns "https://example.com/config"
val sut = ConfigurationLoader(braintreeHttpClient, configurationCache)
sut.loadConfiguration(authorization, callback)
val sut = ConfigurationLoader(braintreeHttpClient, merchantRepository, configurationCache)
sut.loadConfiguration(callback)

val callbackSlot = slot<NetworkResponseCallback>()
verify {
braintreeHttpClient.get(
ofType(String::class),
null,
authorization,
HttpClient.RETRY_MAX_3_TIMES,
capture(callbackSlot)
ofType(String::class),
null,
authorization,
HttpClient.RETRY_MAX_3_TIMES,
capture(callbackSlot)
)
}
val httpResponseCallback = callbackSlot.captured
httpResponseCallback.onResult(HttpResponse("not json", HttpResponseTiming(0, 0)), null)
verify {
callback.onResult(null, ofType(JSONException::class), null)
}

val errorSlot = slot<ConfigurationLoaderResult>()
verify { callback.onResult(capture(errorSlot)) }

assertTrue { (errorSlot.captured as ConfigurationLoaderResult.Failure).error is JSONException }
}

@Test
fun loadConfiguration_onHttpError_forwardsExceptionToErrorResponseListener() {
every { authorization.configUrl } returns "https://example.com/config"
val sut = ConfigurationLoader(braintreeHttpClient, configurationCache)
sut.loadConfiguration(authorization, callback)
val sut = ConfigurationLoader(braintreeHttpClient, merchantRepository, configurationCache)
sut.loadConfiguration(callback)

val callbackSlot = slot<NetworkResponseCallback>()

verify {
braintreeHttpClient.get(
ofType(String::class),
null,
authorization,
HttpClient.RETRY_MAX_3_TIMES,
capture(callbackSlot)
ofType(String::class),
null,
authorization,
HttpClient.RETRY_MAX_3_TIMES,
capture(callbackSlot)
)
}

val httpResponseCallback = callbackSlot.captured
val httpError = Exception("http error")
httpResponseCallback.onResult(null, httpError)
val errorSlot = slot<Exception>()
verify {
callback.onResult(null, capture(errorSlot), null)
}
val errorSlot = slot<ConfigurationLoaderResult>()
verify { callback.onResult(capture(errorSlot)) }

val error = errorSlot.captured as ConfigurationException
assertEquals(
"Request for configuration has failed: http error",
error.message
(errorSlot.captured as ConfigurationLoaderResult.Failure).error.message,
"Request for configuration has failed: http error"
)
}

@Test
fun loadConfiguration_whenInvalidToken_forwardsExceptionToCallback() {
val authorization: Authorization = InvalidAuthorization("invalid", "token invalid")
val sut = ConfigurationLoader(braintreeHttpClient, configurationCache)
sut.loadConfiguration(authorization, callback)
val errorSlot = slot<BraintreeException>()
verify {
callback.onResult(null, capture(errorSlot), null)
}
fun loadConfiguration_whenInvalidToken_exception_is_returned() {
every { merchantRepository.authorization } returns InvalidAuthorization("invalid", "token invalid")
val sut = ConfigurationLoader(braintreeHttpClient, merchantRepository, configurationCache)
sut.loadConfiguration(callback)
val errorSlot = slot<ConfigurationLoaderResult>()
verify { callback.onResult(capture(errorSlot)) }

val exception = errorSlot.captured
assertEquals("token invalid", exception.message)
assertEquals(
(errorSlot.captured as ConfigurationLoaderResult.Failure).error.message,
"Valid authorization required. See " +
"https://developer.paypal.com/braintree/docs/guides/client-sdk/setup/android/v4#initialization " +
"for more info."
)
}

@Test
@@ -163,18 +181,72 @@ class ConfigurationLoaderUnitTest {
every { authorization.bearer } returns "bearer"
every { configurationCache.getConfiguration(cacheKey) } returns Fixtures.CONFIGURATION_WITH_ACCESS_TOKEN

val sut = ConfigurationLoader(braintreeHttpClient, configurationCache)
sut.loadConfiguration(authorization, callback)
val sut = ConfigurationLoader(braintreeHttpClient, merchantRepository, configurationCache)
sut.loadConfiguration(callback)

verify(exactly = 0) {
braintreeHttpClient.get(
ofType(String::class),
null,
authorization,
ofType(Int::class),
ofType(NetworkResponseCallback::class)
ofType(String::class),
null,
authorization,
ofType(Int::class),
ofType(NetworkResponseCallback::class)
)
}

val successSlot = slot<ConfigurationLoaderResult>()
verify { callback.onResult(capture(successSlot)) }

assertTrue { successSlot.captured is ConfigurationLoaderResult.Success }
}

@Test
fun `when loadConfiguration is called and configuration is fetched from the API, analytics event is sent`() {
every { authorization.configUrl } returns "https://example.com/config"
every { merchantRepository.authorization } returns authorization
every { time.currentTime } returns 123

val sut = ConfigurationLoader(
httpClient = braintreeHttpClient,
merchantRepository = merchantRepository,
configurationCache = configurationCache,
time = time,
lazyAnalyticsClient = lazy { analyticsClient }
)
sut.loadConfiguration(callback)

val expectedConfigUrl = "https://example.com/config?configVersion=3"
val callbackSlot = slot<NetworkResponseCallback>()
verify {
braintreeHttpClient.get(
expectedConfigUrl,
null,
authorization,
HttpClient.RETRY_MAX_3_TIMES,
capture(callbackSlot)
)
}
verify { callback.onResult(ofType(Configuration::class), null, null) }

val httpResponseCallback = callbackSlot.captured
httpResponseCallback.onResult(
HttpResponse(Fixtures.CONFIGURATION_WITH_ACCESS_TOKEN, HttpResponseTiming(0, 10)), null
)

verify {
analyticsClient.sendEvent(
AnalyticsEvent(
name = CoreAnalytics.API_REQUEST_LATENCY,
timestamp = 123,
startTime = 0,
endTime = 10,
endpoint = "/v1/configuration"
)
)
}

val successSlot = slot<ConfigurationLoaderResult>()
verify { callback.onResult(capture(successSlot)) }

assertTrue { successSlot.captured is ConfigurationLoaderResult.Success }
}
}
Original file line number Diff line number Diff line change
@@ -6,7 +6,7 @@ import io.mockk.mockk
internal class MockkConfigurationLoaderBuilder {

private var configuration: Configuration? = null
private var configurationError: Exception? = null
private lateinit var configurationError: Exception

fun configuration(configuration: Configuration): MockkConfigurationLoaderBuilder {
this.configuration = configuration
@@ -20,12 +20,12 @@ internal class MockkConfigurationLoaderBuilder {

fun build(): ConfigurationLoader {
val configurationLoader = mockk<ConfigurationLoader>(relaxed = true)
every { configurationLoader.loadConfiguration(any(), any()) } answers {
val callback = secondArg<ConfigurationLoaderCallback>()
if (configuration != null) {
callback.onResult(configuration, null, null)
} else if (configurationError != null) {
callback.onResult(null, configurationError, null)
every { configurationLoader.loadConfiguration(any()) } answers {
val callback = firstArg<ConfigurationLoaderCallback>()
configuration?.let {
callback.onResult(ConfigurationLoaderResult.Success(it))
} ?: run {
callback.onResult(ConfigurationLoaderResult.Failure(configurationError))
}
}
return configurationLoader

0 comments on commit c9d4246

Please sign in to comment.