diff --git a/library/account/build.gradle.kts b/library/account/build.gradle.kts index 7cee5c79e3..b2a11d2b5e 100644 --- a/library/account/build.gradle.kts +++ b/library/account/build.gradle.kts @@ -35,6 +35,7 @@ dependencies { ksp(libs.dagger.compiler) ksp(libs.hilt.compiler) + testImplementation(libs.json) testImplementation(libs.kotlin.coroutines.test) testImplementation(libs.turbine) } diff --git a/library/account/src/main/kotlin/org/cru/godtools/account/provider/AccountProvider.kt b/library/account/src/main/kotlin/org/cru/godtools/account/provider/AccountProvider.kt index 80a2b835e0..2fa546a7e4 100644 --- a/library/account/src/main/kotlin/org/cru/godtools/account/provider/AccountProvider.kt +++ b/library/account/src/main/kotlin/org/cru/godtools/account/provider/AccountProvider.kt @@ -4,8 +4,12 @@ import androidx.activity.result.ActivityResultLauncher import androidx.compose.runtime.Composable import kotlinx.coroutines.flow.Flow import org.ccci.gto.android.common.Ordered +import org.ccci.gto.android.common.jsonapi.JsonApiConverter +import org.ccci.gto.android.common.jsonapi.model.JsonApiObject import org.cru.godtools.account.AccountType import org.cru.godtools.api.model.AuthToken +import org.json.JSONException +import retrofit2.Response internal interface AccountProvider : Ordered { val type: AccountType @@ -24,3 +28,37 @@ internal interface AccountProvider : Ordered { suspend fun authenticateWithMobileContentApi(): Result } + +internal fun Response>.extractAuthToken() = when { + isSuccessful -> { + val obj = body() + val data = obj?.dataSingle + + when { + obj == null -> Result.failure(AuthenticationException.UnknownError) + obj.hasErrors -> obj.parseErrors() + data == null -> Result.failure(AuthenticationException.UnknownError) + else -> Result.success(data) + } + } + else -> errorBody()?.string() + ?.let { + try { + JsonApiConverter.Builder().build().fromJson(it, AuthToken::class.java) + } catch (_: JSONException) { + null + } + } + ?.parseErrors() + ?: Result.failure(AuthenticationException.UnknownError) +} + +private fun JsonApiObject.parseErrors(): Result { + errors.forEach { + when (it.code) { + AuthToken.ERROR_USER_ALREADY_EXISTS -> return Result.failure(AuthenticationException.UserAlreadyExists) + AuthToken.ERROR_USER_NOT_FOUND -> return Result.failure(AuthenticationException.UserNotFound) + } + } + return Result.failure(AuthenticationException.UnknownError) +} diff --git a/library/account/src/main/kotlin/org/cru/godtools/account/provider/AuthenticationException.kt b/library/account/src/main/kotlin/org/cru/godtools/account/provider/AuthenticationException.kt index 524b8367a5..cd2c297614 100644 --- a/library/account/src/main/kotlin/org/cru/godtools/account/provider/AuthenticationException.kt +++ b/library/account/src/main/kotlin/org/cru/godtools/account/provider/AuthenticationException.kt @@ -5,4 +5,7 @@ internal sealed class AuthenticationException : Exception() { data object MissingCredentials : AuthenticationException() data object UnableToRefreshCredentials : AuthenticationException() data object UnknownError : AuthenticationException() + + data object UserAlreadyExists : AuthenticationException() + data object UserNotFound : AuthenticationException() } diff --git a/library/account/src/main/kotlin/org/cru/godtools/account/provider/facebook/FacebookAccountProvider.kt b/library/account/src/main/kotlin/org/cru/godtools/account/provider/facebook/FacebookAccountProvider.kt index 03a63bda26..6df298895c 100644 --- a/library/account/src/main/kotlin/org/cru/godtools/account/provider/facebook/FacebookAccountProvider.kt +++ b/library/account/src/main/kotlin/org/cru/godtools/account/provider/facebook/FacebookAccountProvider.kt @@ -23,6 +23,7 @@ import org.ccci.gto.android.common.kotlin.coroutines.getStringFlow import org.cru.godtools.account.AccountType import org.cru.godtools.account.provider.AccountProvider import org.cru.godtools.account.provider.AuthenticationException +import org.cru.godtools.account.provider.extractAuthToken import org.cru.godtools.api.AuthApi import org.cru.godtools.api.model.AuthToken @@ -80,10 +81,8 @@ internal class FacebookAccountProvider @Inject constructor( resp = accessToken.authenticateWithMobileContentApi() } - val token = resp.takeIf { it.isSuccessful }?.body()?.takeUnless { it.hasErrors }?.dataSingle - ?: return Result.failure(AuthenticationException.UnknownError) - prefs.edit { putString(accessToken.PREF_USER_ID, token.userId) } - return Result.success(token) + return resp.extractAuthToken() + .onSuccess { prefs.edit { putString(accessToken.PREF_USER_ID, it.userId) } } } private suspend fun AccessToken.authenticateWithMobileContentApi() = diff --git a/library/account/src/main/kotlin/org/cru/godtools/account/provider/google/GoogleAccountProvider.kt b/library/account/src/main/kotlin/org/cru/godtools/account/provider/google/GoogleAccountProvider.kt index 4a3759a674..7119ce7d53 100644 --- a/library/account/src/main/kotlin/org/cru/godtools/account/provider/google/GoogleAccountProvider.kt +++ b/library/account/src/main/kotlin/org/cru/godtools/account/provider/google/GoogleAccountProvider.kt @@ -24,6 +24,7 @@ import org.ccci.gto.android.common.play.auth.signin.GoogleSignInKtx import org.cru.godtools.account.AccountType import org.cru.godtools.account.provider.AccountProvider import org.cru.godtools.account.provider.AuthenticationException +import org.cru.godtools.account.provider.extractAuthToken import org.cru.godtools.api.AuthApi import org.cru.godtools.api.model.AuthToken import timber.log.Timber @@ -87,10 +88,8 @@ internal class GoogleAccountProvider @Inject constructor( ?: return Result.failure(AuthenticationException.MissingCredentials) } - val token = resp.takeIf { it.isSuccessful }?.body()?.takeUnless { it.hasErrors }?.dataSingle - ?: return Result.failure(AuthenticationException.UnknownError) - prefs.edit { putString(account.PREF_USER_ID, token.userId) } - return Result.success(token) + return resp.extractAuthToken() + .onSuccess { prefs.edit { putString(account.PREF_USER_ID, it.userId) } } } private suspend fun GoogleSignInAccount.authenticateWithMobileContentApi() = diff --git a/library/account/src/test/kotlin/org/cru/godtools/account/provider/AccountProviderTest.kt b/library/account/src/test/kotlin/org/cru/godtools/account/provider/AccountProviderTest.kt new file mode 100644 index 0000000000..d3824ce525 --- /dev/null +++ b/library/account/src/test/kotlin/org/cru/godtools/account/provider/AccountProviderTest.kt @@ -0,0 +1,87 @@ +package org.cru.godtools.account.provider + +import kotlin.test.Test +import kotlin.test.assertEquals +import okhttp3.ResponseBody.Companion.toResponseBody +import org.ccci.gto.android.common.jsonapi.JsonApiConverter +import org.ccci.gto.android.common.jsonapi.model.JsonApiError +import org.ccci.gto.android.common.jsonapi.model.JsonApiObject +import org.cru.godtools.api.model.AuthToken +import retrofit2.Response + +private const val ERROR_USER_ALREADY_EXISTS = """ + {"errors":[{"code":"user_already_exists","detail":"User account already exists."}]} +""" +private const val ERROR_USER_NOT_FOUND = """ + {"errors":[{"code":"user_not_found","detail":"User account not found."}]} +""" + +class AccountProviderTest { + // region extractAuthToken() + @Test + fun `extractAuthToken() - Successful`() { + val token = AuthToken() + val response = Response.success(JsonApiObject.of(token)) + + assertEquals(token, response.extractAuthToken().getOrNull()) + } + + @Test + fun `extractAuthToken() - Error - Successful status with jsonapi error`() { + val error = JsonApiError(code = AuthToken.ERROR_USER_ALREADY_EXISTS) + val response = Response.success(JsonApiObject.error(error)) + + assertEquals( + AuthenticationException.UserAlreadyExists, + response.extractAuthToken().exceptionOrNull() + ) + } + + @Test + fun `extractAuthToken() - Error - 500 ISE`() { + val response = Response.error>(500, "500 ISE".toResponseBody()) + + assertEquals( + AuthenticationException.UnknownError, + response.extractAuthToken().exceptionOrNull() + ) + } + + @Test + fun `extractAuthToken() - Error - User Already Exists`() { + val response = Response.error>(400, ERROR_USER_ALREADY_EXISTS.toResponseBody()) + + assertEquals( + AuthenticationException.UserAlreadyExists, + response.extractAuthToken().exceptionOrNull() + ) + } + + @Test + fun `extractAuthToken() - Error - User Not Found`() { + val response = Response.error>(400, ERROR_USER_NOT_FOUND.toResponseBody()) + + assertEquals( + AuthenticationException.UserNotFound, + response.extractAuthToken().exceptionOrNull() + ) + } + + @Test + fun `extractAuthToken() - Error - Valid jsonapi response with error status`() { + val converter = JsonApiConverter.Builder() + .addClasses(AuthToken::class.java) + .build() + + val response = Response.error>( + 400, + converter.toJson(JsonApiObject.of(AuthToken())).toResponseBody() + ) + + assertEquals( + AuthenticationException.UnknownError, + response.extractAuthToken().exceptionOrNull() + ) + } + // endregion extractAuthToken() +} diff --git a/library/api/src/main/kotlin/org/cru/godtools/api/model/AuthToken.kt b/library/api/src/main/kotlin/org/cru/godtools/api/model/AuthToken.kt index c4520b40a5..07e1dfe62e 100644 --- a/library/api/src/main/kotlin/org/cru/godtools/api/model/AuthToken.kt +++ b/library/api/src/main/kotlin/org/cru/godtools/api/model/AuthToken.kt @@ -16,6 +16,11 @@ data class AuthToken( @JsonApiAttribute(ATTR_TOKEN) var token: String? = null ) { + companion object { + const val ERROR_USER_ALREADY_EXISTS = "user_already_exists" + const val ERROR_USER_NOT_FOUND = "user_not_found" + } + @JsonApiType("auth-token-request") data class Request( @JsonApiAttribute(ATTR_FACEBOOK_ACCESS_TOKEN) val fbAccessToken: String? = null,