diff --git a/tasks-app-shared/src/commonMain/kotlin/net/opatry/google/profile/UserInfoErrorResponse.kt b/tasks-app-shared/src/commonMain/kotlin/net/opatry/google/profile/UserInfoErrorResponse.kt deleted file mode 100644 index e13eb014..00000000 --- a/tasks-app-shared/src/commonMain/kotlin/net/opatry/google/profile/UserInfoErrorResponse.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (c) 2025 Olivier Patry - * - * Permission is hereby granted, free of charge, to any person obtaining - * a copy of this software and associated documentation files (the "Software"), - * to deal in the Software without restriction, including without limitation - * the rights to use, copy, modify, merge, publish, distribute, sublicense, - * and/or sell copies of the Software, and to permit persons to whom the Software - * is furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES - * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY - * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, - * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE - * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -package net.opatry.google.profile - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class UserInfoErrorResponse( - @SerialName("error") - val error: Error, -) { - @Serializable - data class Error( - @SerialName("code") - val code: Int, - @SerialName("message") - val message: String, - @SerialName("status") - val status: String, - ) -} \ No newline at end of file diff --git a/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/presentation/UserViewModel.kt b/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/presentation/UserViewModel.kt index 3622e7cd..c929f679 100644 --- a/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/presentation/UserViewModel.kt +++ b/tasks-app-shared/src/commonMain/kotlin/net/opatry/tasks/app/presentation/UserViewModel.kt @@ -25,14 +25,11 @@ package net.opatry.tasks.app.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.ktor.client.plugins.ResponseException -import io.ktor.client.statement.bodyAsText import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import kotlinx.serialization.json.Json import net.opatry.google.auth.GoogleAuthenticator import net.opatry.google.profile.UserInfoApi -import net.opatry.google.profile.UserInfoErrorResponse import net.opatry.google.profile.model.UserInfo import net.opatry.logging.Logger import net.opatry.tasks.CredentialsStorage @@ -79,9 +76,9 @@ class UserViewModel( return try { userInfoApi.getUserInfo() } catch (e: ResponseException) { - Json.decodeFromString(e.response.bodyAsText()).also { response -> - logger.logError("Error while fetching user info: ${response.error.message}", e) - } + // don't assume we can read response accurately (see https://github.com/opatry/taskfolio/issues/262) + // API is poorly documented and 401 & 400 do not return the same data for sure + logger.logError("Web Service error while fetching user info", e) null } catch (e: Exception) { // most likely no network diff --git a/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/presentation/UserViewModelTest.kt b/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/presentation/UserViewModelTest.kt index 3be31bec..b333571c 100644 --- a/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/presentation/UserViewModelTest.kt +++ b/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/presentation/UserViewModelTest.kt @@ -22,6 +22,7 @@ package net.opatry.tasks.presentation +import io.ktor.client.plugins.ResponseException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -41,9 +42,12 @@ import net.opatry.test.MainDispatcherRule import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.mockito.BDDMockito.given import org.mockito.BDDMockito.then import org.mockito.InjectMocks import org.mockito.Mock +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify import org.mockito.Mockito.`when` import org.mockito.junit.MockitoJUnitRunner import kotlin.test.assertEquals @@ -167,13 +171,73 @@ class UserViewModelTest { assertEquals(UserState.Unsigned, viewModel.state.value) } + @Test + fun `signIn stores token but updates state to Unsigned on WS failure when fetching user info`() = runTest { + given(nowProvider.now()) + .willReturn(Instant.fromEpochMilliseconds(42L)) + val exception = mock() + given(userInfoApi.getUserInfo()).willThrow(exception) + + viewModel.signIn( + GoogleAuthenticator.OAuthToken( + accessToken = "accessToken", + expiresIn = 10L, + idToken = "idToken", + refreshToken = "refreshToken", + scope = "scope", + tokenType = GoogleAuthenticator.OAuthToken.TokenType.Bearer, + ) + ) + advanceUntilIdle() + + verify(credentialsStorage).store( + TokenCache( + accessToken = "accessToken", + refreshToken = "refreshToken", + expirationTimeMillis = 42L + 10L.seconds.inWholeMilliseconds, + ) + ) + verify(logger).logError("Web Service error while fetching user info", exception) + assertEquals(UserState.Unsigned, viewModel.state.value) + } + + @Test + fun `signIn stores token but updates state to Unsigned on unknown failure when fetching user info`() = runTest { + given(nowProvider.now()) + .willReturn(Instant.fromEpochMilliseconds(42L)) + val exception = mock() + given(userInfoApi.getUserInfo()).willThrow(exception) + + viewModel.signIn( + GoogleAuthenticator.OAuthToken( + accessToken = "accessToken", + expiresIn = 10L, + idToken = "idToken", + refreshToken = "refreshToken", + scope = "scope", + tokenType = GoogleAuthenticator.OAuthToken.TokenType.Bearer, + ) + ) + advanceUntilIdle() + + verify(credentialsStorage).store( + TokenCache( + accessToken = "accessToken", + refreshToken = "refreshToken", + expirationTimeMillis = 42L + 10L.seconds.inWholeMilliseconds, + ) + ) + verify(logger).logError("Error while fetching user info", exception) + assertEquals(UserState.Unsigned, viewModel.state.value) + } + @Test fun `signOut clears token clears signed in status and updates state to Unsigned`() = runTest { viewModel.signOut() advanceUntilIdle() - then(credentialsStorage).should().store(TokenCache()) - then(userDao).should().clearAllSignedInStatus() + verify(credentialsStorage).store(TokenCache()) + verify(userDao).clearAllSignedInStatus() assertEquals(UserState.Unsigned, viewModel.state.value) }