Skip to content

Commit

Permalink
identity v3 (#250)
Browse files Browse the repository at this point in the history
* added starter code for logIn

* added basic logic to IdentityManager for logIn

* added basic backend logic for logIn

* fix imports

* fixed more imports, removed magic number

* added logOut

* detekt

* made logIn and logOut internal temporarily

* added logInWith listener conversion

* fixed a bug where if the appUserID was empty, login would still contact the backend. Added tests for logIn in identityManager

* added tests for identifierManager.logOut

* wip adding backend tests for logIn + considering case where purchaserInfo can't be parsed from the backend's response

* added more tests for backend

* added final backend tests for the login endpoint

* added skeleton for unit tests for login in purchases

* added logout test skeletons

* more test cases for login in Purchases

* added more test cases and fixed a bug where offerings would be fetched for the old app user id instead of the new one after a successful login

* re-wrote logOut to use the identityManager's logic and added more tests

* fix tests

* fixed more tests

* added error log for login errors in backend.kt

* more logging cleanup

* removed unused import

* fixed formatting in a few places

* more fixed indentation

* added @JvmSynthetic in a couple of places

* updated imports for identity strings to use the namespace instead of a full import

* updated loginListener -> LogInCallback and moved it to kotlin

* removed unnecessary line breaks

* extracted log string into identity strings

* fail<String> -> fail

* removed unnecessary mock completion

* small cleanup

* added explicit call count to verify calls
  • Loading branch information
aboedo authored Feb 19, 2021
1 parent 6c88d62 commit 7d98ce7
Show file tree
Hide file tree
Showing 10 changed files with 793 additions and 11 deletions.
38 changes: 38 additions & 0 deletions common/src/main/java/com/revenuecat/purchases/common/Backend.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ package com.revenuecat.purchases.common
import android.net.Uri
import com.revenuecat.purchases.PurchaserInfo
import com.revenuecat.purchases.PurchasesError
import com.revenuecat.purchases.PurchasesErrorCode
import com.revenuecat.purchases.common.attribution.AttributionNetwork
import org.json.JSONException
import org.json.JSONObject

private const val HTTP_STATUS_CREATED = 201
private const val UNSUCCESSFUL_HTTP_STATUS_CODE = 300
const val HTTP_SERVER_ERROR_CODE = 500
const val HTTP_NOT_FOUND_ERROR_CODE = 404
Expand Down Expand Up @@ -330,6 +332,42 @@ class Backend(
})
}

fun logIn(
appUserID: String,
newAppUserID: String,
onSuccessHandler: (PurchaserInfo, Boolean) -> Unit,
onErrorHandler: (PurchasesError) -> Unit
) {
enqueue(object : Dispatcher.AsyncCall() {
override fun call(): HTTPClient.Result {
return httpClient.performRequest(
"/subscribers/" + encode(appUserID) + "/login",
mapOf("new_app_user_id" to newAppUserID),
authenticationHeaders
)
}

override fun onError(error: PurchasesError) {
onErrorHandler(error)
}

override fun onCompletion(result: HTTPClient.Result) {
if (result.isSuccessful()) {
val created = result.responseCode == HTTP_STATUS_CREATED
if (result.body.length() > 0) {
val purchaserInfo = result.body.buildPurchaserInfo()
onSuccessHandler(purchaserInfo, created)
} else {
onErrorHandler(PurchasesError(PurchasesErrorCode.UnknownError)
.also { errorLog(it) })
}
} else {
onErrorHandler(result.toPurchasesError().also { errorLog(it) })
}
}
})
}

private fun HTTPClient.Result.isSuccessful(): Boolean {
return responseCode < UNSUCCESSFUL_HTTP_STATUS_CODE
}
Expand Down
166 changes: 161 additions & 5 deletions common/src/test/java/com/revenuecat/purchases/common/BackendTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.android.billingclient.api.SkuDetails
import com.revenuecat.purchases.PurchaserInfo
import com.revenuecat.purchases.PurchasesError
import com.revenuecat.purchases.PurchasesErrorCode
import com.revenuecat.purchases.common.attribution.AttributionNetwork
import com.revenuecat.purchases.utils.Responses
import com.revenuecat.purchases.utils.getNullableString
import io.mockk.Called
import io.mockk.every
Expand Down Expand Up @@ -66,6 +68,7 @@ class BackendTest {
private val appUserID = "jerry"

private var receivedPurchaserInfo: PurchaserInfo? = null
private var receivedCreated: Boolean? = null
private var receivedOfferingsJSON: JSONObject? = null
private var receivedError: PurchasesError? = null

Expand Down Expand Up @@ -95,6 +98,15 @@ class BackendTest {
this@BackendTest.receivedError = it
}

private val onLoginSuccessHandler: (PurchaserInfo, Boolean) -> Unit = { purchaserInfo, created ->
this@BackendTest.receivedPurchaserInfo = purchaserInfo
this@BackendTest.receivedCreated = created
}

private val onReceiveLoginErrorHandler: (PurchasesError) -> Unit = {
this@BackendTest.receivedError = it
}

@Test
fun canBeCreated() {
assertThat(backend).isNotNull
Expand All @@ -106,7 +118,8 @@ class BackendTest {
responseCode: Int,
clientException: Exception?,
resultBody: String?,
delayed: Boolean = false
delayed: Boolean = false,
shouldMockPurchaserInfo: Boolean = true
): PurchaserInfo {
val info: PurchaserInfo = mockk()

Expand All @@ -115,10 +128,11 @@ class BackendTest {
val headers = HashMap<String, String>()
headers["Authorization"] = "Bearer $API_KEY"

every {
result.body.buildPurchaserInfo()
} returns info

if (shouldMockPurchaserInfo) {
every {
result.body.buildPurchaserInfo()
} returns info
}
val everyMockedCall = every {
mockClient.performRequest(
eq(path),
Expand Down Expand Up @@ -946,6 +960,148 @@ class BackendTest {
assertThat(calledWithRandomDelay).isTrue()
}

@Test
fun `logIn makes the right http call`() {
val newAppUserID = "newId"
val body = mapOf(
"new_app_user_id" to newAppUserID
)
mockResponse(
"/subscribers/$appUserID/login",
body,
201,
null,
Responses.validFullPurchaserResponse
)

backend.logIn(
appUserID,
newAppUserID,
onLoginSuccessHandler,
{
fail("Should have called success")
}
)
verify(exactly = 1) {
mockClient.performRequest(
"/subscribers/$appUserID/login",
body,
any()
)
}
}

@Test
fun `logIn correctly parses purchaserInfo`() {
val newAppUserID = "newId"
val requestBody = mapOf(
"new_app_user_id" to newAppUserID
)
val resultBody = Responses.validFullPurchaserResponse
mockResponse(
"/subscribers/$appUserID/login",
requestBody,
responseCode = 201,
clientException = null,
resultBody = resultBody,
delayed = false,
shouldMockPurchaserInfo = false
)
val expectedPurchaserInfo = JSONObject(resultBody).buildPurchaserInfo()

backend.logIn(
appUserID,
newAppUserID,
onLoginSuccessHandler,
{
fail("Should have called success")
}
)
assertThat(receivedPurchaserInfo).isEqualTo(expectedPurchaserInfo)
assertThat(receivedCreated).isEqualTo(true)
}

@Test
fun `logIn calls OnError if purchaserInfo can't be parsed`() {
val newAppUserID = "newId"
val requestBody = mapOf(
"new_app_user_id" to newAppUserID
)
val resultBody = "{}"
mockResponse(
"/subscribers/$appUserID/login",
requestBody,
responseCode = 201,
clientException = null,
resultBody = resultBody,
delayed = false,
shouldMockPurchaserInfo = false
)

backend.logIn(
appUserID,
newAppUserID,
{ _, _ ->
fail("Should have called success")
},
onReceiveLoginErrorHandler
)
assertThat(receivedError).isNotNull
assertThat(receivedError?.code).isEqualTo(PurchasesErrorCode.UnknownError)
}

@Test
fun `logIn returns created true if status is 201`() {
val newAppUserID = "newId"
val requestBody = mapOf(
"new_app_user_id" to newAppUserID
)
val resultBody = Responses.validFullPurchaserResponse
mockResponse(
"/subscribers/$appUserID/login",
requestBody,
responseCode = 201,
clientException = null,
resultBody = resultBody
)

backend.logIn(
appUserID,
newAppUserID,
onLoginSuccessHandler,
{
fail("Should have called success")
}
)
assertThat(receivedCreated).isTrue()
}

@Test
fun `logIn returns created false if status isn't 201`() {
val newAppUserID = "newId"
val requestBody = mapOf(
"new_app_user_id" to newAppUserID
)
val resultBody = Responses.validFullPurchaserResponse
mockResponse(
"/subscribers/$appUserID/login",
requestBody,
responseCode = 200,
clientException = null,
resultBody = resultBody
)

backend.logIn(
appUserID,
newAppUserID,
onLoginSuccessHandler,
{
fail("Should have called success")
}
)
assertThat(receivedCreated).isFalse
}

private fun mockSkuDetails(
price: Long = 25000000,
duration: String = "P1M",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package com.revenuecat.purchases.identity

import com.revenuecat.purchases.PurchaserInfo
import com.revenuecat.purchases.PurchasesError
import com.revenuecat.purchases.PurchasesErrorCode
import com.revenuecat.purchases.common.Backend
import com.revenuecat.purchases.common.LogIntent
import com.revenuecat.purchases.common.caching.DeviceCache
import com.revenuecat.purchases.common.errorLog
import com.revenuecat.purchases.common.log
import com.revenuecat.purchases.strings.IdentityStrings
import com.revenuecat.purchases.subscriberattributes.caching.SubscriberAttributesCache
Expand Down Expand Up @@ -49,6 +52,42 @@ class IdentityManager(
}
}

fun logIn(
newAppUserID: String,
onSuccess: (PurchaserInfo, Boolean) -> Unit,
onError: (PurchasesError) -> Unit
) {
if (newAppUserID.isBlank()) {
onError(PurchasesError(
PurchasesErrorCode.InvalidAppUserIdError,
IdentityStrings.LOG_IN_ERROR_MISSING_APP_USER_ID
).also { errorLog(it) })
return
}

log(LogIntent.USER, IdentityStrings.LOGGING_IN.format(currentAppUserID, newAppUserID))
val oldAppUserID = currentAppUserID
backend.logIn(
oldAppUserID,
newAppUserID,
{ purchaserInfo, created ->
synchronized(this@IdentityManager) {
log(
LogIntent.USER,
IdentityStrings.LOG_IN_SUCCESSFUL.format(newAppUserID, created)
)
deviceCache.clearCachesForAppUserID(oldAppUserID)
subscriberAttributesCache.clearSubscriberAttributesIfSyncedForSubscriber(oldAppUserID)

deviceCache.cacheAppUserID(newAppUserID)
deviceCache.cachePurchaserInfo(newAppUserID, purchaserInfo)
}
onSuccess(purchaserInfo, created)
},
onError
)
}

fun createAlias(
newAppUserID: String,
onSuccess: () -> Unit,
Expand Down Expand Up @@ -78,6 +117,18 @@ class IdentityManager(
deviceCache.cacheAppUserID(generateRandomID())
}

@Synchronized
fun logOut(): PurchasesError? {
if (currentUserIsAnonymous()) {
log(LogIntent.RC_ERROR, IdentityStrings.LOG_OUT_CALLED_ON_ANONYMOUS_USER)
return PurchasesError(PurchasesErrorCode.LogOutWithAnonymousUserError)
}
deviceCache.clearLatestAttributionData(currentAppUserID)
reset()
log(LogIntent.USER, IdentityStrings.LOG_OUT_SUCCESSFUL)
return null
}

@Synchronized
fun currentUserIsAnonymous(): Boolean {
val currentAppUserIDLooksAnonymous =
Expand Down
Loading

0 comments on commit 7d98ce7

Please sign in to comment.