diff --git a/app/src/test/kotlin/com/andreolas/movierama/details/ui/DetailsViewModelRobot.kt b/app/src/test/kotlin/com/andreolas/movierama/details/ui/DetailsViewModelRobot.kt index 04aeb231..e117b9a7 100644 --- a/app/src/test/kotlin/com/andreolas/movierama/details/ui/DetailsViewModelRobot.kt +++ b/app/src/test/kotlin/com/andreolas/movierama/details/ui/DetailsViewModelRobot.kt @@ -7,6 +7,7 @@ import com.andreolas.movierama.fakes.usecase.FakeMarkAsFavoriteUseCase import com.andreolas.movierama.fakes.usecase.details.FakeAddToWatchlistUseCase import com.andreolas.movierama.fakes.usecase.details.FakeDeleteRatingUseCase import com.andreolas.movierama.fakes.usecase.details.FakeSubmitRatingUseCase +import com.divinelink.core.model.jellyseerr.request.JellyseerrMediaRequest import com.divinelink.core.model.media.MediaItem import com.divinelink.core.model.media.MediaType import com.divinelink.core.testing.MainDispatcherRule @@ -65,6 +66,10 @@ class DetailsViewModelRobot { ) } + fun mockRequestMedia(response: Flow>) = apply { + fakeRequestMediaUseCase.mockSuccess(response = response) + } + fun onAddRateClicked() = apply { viewModel.onAddRateClicked() } @@ -93,6 +98,10 @@ class DetailsViewModelRobot { viewModel.onMarkAsFavorite() } + fun onRequestMedia(seasons: List) = apply { + viewModel.onRequestMedia(seasons) + } + fun consumeSnackbar() = apply { viewModel.consumeSnackbarMessage() } diff --git a/app/src/test/kotlin/com/andreolas/movierama/details/ui/DetailsViewModelTest.kt b/app/src/test/kotlin/com/andreolas/movierama/details/ui/DetailsViewModelTest.kt index 2902ddf4..8e434c19 100644 --- a/app/src/test/kotlin/com/andreolas/movierama/details/ui/DetailsViewModelTest.kt +++ b/app/src/test/kotlin/com/andreolas/movierama/details/ui/DetailsViewModelTest.kt @@ -2,15 +2,18 @@ package com.andreolas.movierama.details.ui +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarResult import com.andreolas.factories.ReviewFactory import com.andreolas.factories.VideoFactory import com.andreolas.factories.details.domain.model.account.AccountMediaDetailsFactory import com.andreolas.factories.details.domain.model.account.AccountMediaDetailsFactory.toWizard +import com.divinelink.core.commons.exception.InvalidStatusException import com.divinelink.core.data.details.model.MediaDetailsException import com.divinelink.core.data.session.model.SessionException import com.divinelink.core.model.account.AccountMediaDetails import com.divinelink.core.model.details.DetailsMenuOptions +import com.divinelink.core.model.jellyseerr.request.JellyseerrMediaRequest import com.divinelink.core.model.media.MediaType import com.divinelink.core.testing.MainDispatcherRule import com.divinelink.core.testing.factories.details.credits.AggregatedCreditsFactory @@ -963,4 +966,135 @@ class DetailsViewModelTest { ), ) } + + @Test + fun `test request movie with null success message`() = runTest { + testRobot + .mockFetchMediaDetails( + response = flowOf(Result.success(MediaDetailsResult.DetailsSuccess(movieDetails))), + ) + .mockRequestMedia( + response = flowOf(Result.success(JellyseerrMediaRequest(null))), + ) + .buildViewModel(mediaId, MediaType.MOVIE) + .onRequestMedia(emptyList()) + .assertViewState( + DetailsViewState( + mediaType = MediaType.MOVIE, + mediaId = mediaId, + isLoading = false, + userDetails = null, + mediaDetails = movieDetails, + snackbarMessage = SnackbarMessage.from( + text = UIText.ResourceText( + R.string.feature_details_jellyseerr_success_media_request, + movieDetails.title, + ), + ), + ), + ) + } + + @Test + fun `test request movie with success message`() = runTest { + testRobot + .mockFetchMediaDetails( + response = flowOf(Result.success(MediaDetailsResult.DetailsSuccess(movieDetails))), + ) + .mockRequestMedia( + response = flowOf(Result.success(JellyseerrMediaRequest("Success"))), + ) + .buildViewModel(mediaId, MediaType.MOVIE) + .onRequestMedia(emptyList()) + .assertViewState( + DetailsViewState( + mediaType = MediaType.MOVIE, + mediaId = mediaId, + isLoading = false, + userDetails = null, + mediaDetails = movieDetails, + snackbarMessage = SnackbarMessage.from( + text = UIText.StringText("Success"), + ), + ), + ) + } + + @Test + fun `test request with 403 prompts to re-login`() = runTest { + val viewModel: DetailsViewModel + testRobot + .mockFetchMediaDetails( + response = flowOf(Result.success(MediaDetailsResult.DetailsSuccess(movieDetails))), + ) + .mockRequestMedia(flowOf(Result.failure(InvalidStatusException(403)))) + .buildViewModel(mediaId, MediaType.MOVIE).also { + viewModel = it.getViewModel() + } + .onRequestMedia(emptyList()) + .assertViewState( + DetailsViewState( + mediaType = MediaType.MOVIE, + mediaId = mediaId, + isLoading = false, + userDetails = null, + mediaDetails = movieDetails, + snackbarMessage = SnackbarMessage.from( + text = UIText.ResourceText(uiR.string.core_ui_jellyseerr_session_expired), + actionLabelText = UIText.ResourceText(uiR.string.core_ui_login), + duration = SnackbarDuration.Long, + onSnackbarResult = viewModel::navigateToLogin, + ), + ), + ) + } + + @Test + fun `test request with 409 informs that movie already exists`() = runTest { + testRobot + .mockFetchMediaDetails( + response = flowOf(Result.success(MediaDetailsResult.DetailsSuccess(movieDetails))), + ) + .mockRequestMedia(flowOf(Result.failure(InvalidStatusException(409)))) + .buildViewModel(mediaId, MediaType.MOVIE) + .onRequestMedia(emptyList()) + .assertViewState( + DetailsViewState( + mediaType = MediaType.MOVIE, + mediaId = mediaId, + isLoading = false, + userDetails = null, + mediaDetails = movieDetails, + snackbarMessage = SnackbarMessage.from( + text = UIText.ResourceText(R.string.feature_details_jellyseerr_request_exists), + ), + ), + ) + } + + @Test + fun `test request with generic error show generic message`() = runTest { + testRobot + .mockFetchMediaDetails( + response = flowOf(Result.success(MediaDetailsResult.DetailsSuccess(movieDetails))), + ) + .mockRequestMedia(flowOf(Result.failure(InvalidStatusException(500)))) + .buildViewModel(mediaId, MediaType.MOVIE) + .onRequestMedia(emptyList()) + .assertViewState( + DetailsViewState( + mediaType = MediaType.MOVIE, + mediaId = mediaId, + isLoading = false, + userDetails = null, + mediaDetails = movieDetails, + snackbarMessage = SnackbarMessage.from( + text = UIText.ResourceText( + R.string.feature_details_jellyseerr_request_failed, + movieDetails.title, + ), + ), + ), + ) + } } diff --git a/app/src/test/kotlin/com/andreolas/movierama/session/SessionStorageTest.kt b/app/src/test/kotlin/com/andreolas/movierama/session/SessionStorageTest.kt index ecf63cdc..bddef4d6 100644 --- a/app/src/test/kotlin/com/andreolas/movierama/session/SessionStorageTest.kt +++ b/app/src/test/kotlin/com/andreolas/movierama/session/SessionStorageTest.kt @@ -137,6 +137,7 @@ class SessionStorageTest { ) val encryptedPreferenceStorage = FakeEncryptedPreferenceStorage( jellyseerrAuthCookie = "123456789qwertyuiop", + jellyseerrPassword = "password", ) val sessionStorage = SessionStorage( @@ -157,5 +158,6 @@ class SessionStorageTest { assertThat(preferenceStorage.jellyseerrAddress.first()).isNull() assertThat(preferenceStorage.jellyseerrSignInMethod.first()).isNull() assertThat(encryptedPreferenceStorage.jellyseerrAuthCookie).isNull() + assertThat(encryptedPreferenceStorage.jellyseerrPassword).isNull() } } diff --git a/core/commons/src/main/kotlin/com/divinelink/core/commons/ErrorHandler.kt b/core/commons/src/main/kotlin/com/divinelink/core/commons/ErrorHandler.kt index 6bad7e8d..82d98d49 100644 --- a/core/commons/src/main/kotlin/com/divinelink/core/commons/ErrorHandler.kt +++ b/core/commons/src/main/kotlin/com/divinelink/core/commons/ErrorHandler.kt @@ -2,30 +2,35 @@ package com.divinelink.core.commons import com.divinelink.core.commons.exception.InvalidStatusException -class ErrorHandler(private val throwable: Throwable) { +class ErrorHandler private constructor() { private var actions = mutableMapOf Unit>() - - val exceptionActions: MutableMap, (Throwable) -> Unit> = mutableMapOf() - private var otherwiseAction: ((Throwable) -> Unit)? = null + val exceptionActions: MutableMap, (Throwable) -> Unit> = mutableMapOf() companion object { + val global = ErrorHandler() + fun create( throwable: Throwable, actions: ErrorHandler.() -> Unit, - ) = ErrorHandler(throwable).apply(actions).handle() + ) = ErrorHandler().apply(actions).handle(throwable) } + /** + * Registers an action to be executed when an error with the specified code occurs. + */ fun on( errorCode: Int, action: (ErrorHandler) -> Unit, ) = apply { actions[errorCode] = action - return this } - inline fun on(noinline action: (Throwable) -> Unit): ErrorHandler = + /** + * Registers an action to be executed when an exception of the specified type occurs. + */ + inline fun on(noinline action: (Throwable) -> Unit): ErrorHandler = apply { exceptionActions[T::class.java] = action } @@ -43,7 +48,10 @@ class ErrorHandler(private val throwable: Throwable) { * This method is used to handle the error based on the error code or the exception type. * If no error code is found, it will call the [otherwiseAction] method. */ - fun handle() { + fun handle( + throwable: Throwable, + skipGlobal: Boolean = false, + ) { val errorCode = getErrorCode(throwable) val action = actions[errorCode] val exceptionAction = findExceptionAction(throwable) @@ -54,6 +62,12 @@ class ErrorHandler(private val throwable: Throwable) { if (action == null && exceptionAction == null) { otherwiseAction?.invoke(throwable) } + + // If global handling is not skipped, pass the error to the global handler + if (!skipGlobal) { + // Ensure global handler doesn't recurse + global.handle(throwable, skipGlobal = true) + } } private fun findExceptionAction(throwable: Throwable?): ((Throwable) -> Unit)? { @@ -75,10 +89,7 @@ class ErrorHandler(private val throwable: Throwable) { val status = throwable.cause as InvalidStatusException status.status } - throwable is InvalidStatusException -> { - val status = throwable - status.status - } + throwable is InvalidStatusException -> throwable.status else -> -1 } } diff --git a/core/data/src/main/kotlin/com/divinelink/core/data/jellyseerr/repository/ProdJellyseerrRepository.kt b/core/data/src/main/kotlin/com/divinelink/core/data/jellyseerr/repository/ProdJellyseerrRepository.kt index 60bdf8fc..3361e261 100644 --- a/core/data/src/main/kotlin/com/divinelink/core/data/jellyseerr/repository/ProdJellyseerrRepository.kt +++ b/core/data/src/main/kotlin/com/divinelink/core/data/jellyseerr/repository/ProdJellyseerrRepository.kt @@ -4,6 +4,7 @@ import app.cash.sqldelight.coroutines.asFlow import app.cash.sqldelight.coroutines.mapToOneOrNull import com.divinelink.core.commons.domain.DispatcherProvider import com.divinelink.core.data.jellyseerr.mapper.map +import com.divinelink.core.database.JellyseerrAccountDetailsQueries import com.divinelink.core.database.jellyseerr.mapper.map import com.divinelink.core.database.jellyseerr.mapper.mapToEntity import com.divinelink.core.model.jellyseerr.JellyseerrAccountDetails @@ -12,9 +13,7 @@ import com.divinelink.core.model.jellyseerr.request.JellyseerrMediaRequest import com.divinelink.core.network.jellyseerr.model.JellyseerrRequestMediaBodyApi import com.divinelink.core.network.jellyseerr.model.map import com.divinelink.core.network.jellyseerr.service.JellyseerrService -import com.divinelink.core.database.JellyseerrAccountDetailsQueries import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map class ProdJellyseerrRepository( @@ -27,23 +26,13 @@ class ProdJellyseerrRepository( loginData: JellyseerrLoginData, ): Flow> = service .signInWithJellyfin(loginData) - .map { - Result.success(it.map()) - } - .catch { error -> - throw error - } + .map { Result.success(it.map()) } override suspend fun signInWithJellyseerr( loginData: JellyseerrLoginData, ): Flow> = service .signInWithJellyseerr(loginData) - .map { - Result.success(it.map()) - } - .catch { error -> - throw error - } + .map { Result.success(it.map()) } override fun getJellyseerrAccountDetails(): Flow = queries .selectAll() @@ -62,21 +51,11 @@ class ProdJellyseerrRepository( } override suspend fun logout(address: String): Flow> = service.logout(address) - .map { - Result.success(Unit) - } - .catch { error -> - throw error - } + .map { Result.success(Unit) } override suspend fun requestMedia( body: JellyseerrRequestMediaBodyApi, ): Flow> = service .requestMedia(body) - .map { - Result.success(it.map()) - } - .catch { error -> - throw error - } + .map { Result.success(it.map()) } } diff --git a/core/datastore/src/main/java/com/divinelink/core/datastore/EncryptedPreferenceStorage.kt b/core/datastore/src/main/java/com/divinelink/core/datastore/EncryptedPreferenceStorage.kt index 128b2be1..6214641e 100644 --- a/core/datastore/src/main/java/com/divinelink/core/datastore/EncryptedPreferenceStorage.kt +++ b/core/datastore/src/main/java/com/divinelink/core/datastore/EncryptedPreferenceStorage.kt @@ -19,11 +19,15 @@ interface EncryptedStorage { suspend fun clearJellyseerrAuthCookie() suspend fun setJellyseerrAuthCookie(cookie: String) val jellyseerrAuthCookie: String? + + suspend fun clearJellyseerrPassword() + suspend fun setJellyseerrPassword(password: String) + val jellyseerrPassword: String? } class EncryptedPreferenceStorage( private val preferenceStorage: PreferenceStorage, - val context: Context, + context: Context, ) : EncryptedStorage { private var masterKey: MasterKey = MasterKey @@ -44,6 +48,7 @@ class EncryptedPreferenceStorage( const val SECRET_TMDB_AUTH_TOKEN = "secret.tmdb.auth.token" const val SECRET_TMDB_SESSION_ID = "secret.tmdb.token.id" const val SECRET_JELLYSEERR_AUTH_COOKIE = "secret.jellyseerr.auth.cookie" + const val SECRET_JELLYSEERR_PASSWORD = "secret.jellyseerr.password" } override suspend fun setTmdbAuthToken(key: String) { @@ -90,6 +95,23 @@ class EncryptedPreferenceStorage( override val jellyseerrAuthCookie: String? get() = encryptedPreferences.getString(PreferencesKeys.SECRET_JELLYSEERR_AUTH_COOKIE, null) + override suspend fun clearJellyseerrPassword() { + with(encryptedPreferences.edit()) { + remove(PreferencesKeys.SECRET_JELLYSEERR_PASSWORD) + apply() + } + } + + override suspend fun setJellyseerrPassword(password: String) { + with(encryptedPreferences.edit()) { + putString(PreferencesKeys.SECRET_JELLYSEERR_PASSWORD, password) + apply() + } + } + + override val jellyseerrPassword: String? + get() = encryptedPreferences.getString(PreferencesKeys.SECRET_JELLYSEERR_PASSWORD, null) + /** * Known issue: https://issuetracker.google.com/issues/158234058 * This library makes the app crash when re-installing, diff --git a/core/datastore/src/main/java/com/divinelink/core/datastore/SessionStorage.kt b/core/datastore/src/main/java/com/divinelink/core/datastore/SessionStorage.kt index d63c346d..693e58d8 100644 --- a/core/datastore/src/main/java/com/divinelink/core/datastore/SessionStorage.kt +++ b/core/datastore/src/main/java/com/divinelink/core/datastore/SessionStorage.kt @@ -40,6 +40,7 @@ class SessionStorage( } suspend fun clearJellyseerrSession() { + encryptedStorage.clearJellyseerrPassword() encryptedStorage.clearJellyseerrAuthCookie() storage.clearJellyseerrAccount() storage.clearJellyseerrSignInMethod() diff --git a/core/domain/src/main/kotlin/com/divinelink/core/domain/jellyseerr/LoginJellyseerrUseCase.kt b/core/domain/src/main/kotlin/com/divinelink/core/domain/jellyseerr/LoginJellyseerrUseCase.kt index 0eeeba3c..30aac611 100644 --- a/core/domain/src/main/kotlin/com/divinelink/core/domain/jellyseerr/LoginJellyseerrUseCase.kt +++ b/core/domain/src/main/kotlin/com/divinelink/core/domain/jellyseerr/LoginJellyseerrUseCase.kt @@ -1,12 +1,13 @@ package com.divinelink.core.domain.jellyseerr +import com.divinelink.core.commons.domain.DispatcherProvider import com.divinelink.core.commons.domain.FlowUseCase import com.divinelink.core.data.jellyseerr.repository.JellyseerrRepository +import com.divinelink.core.datastore.EncryptedStorage import com.divinelink.core.datastore.PreferenceStorage import com.divinelink.core.model.jellyseerr.JellyseerrAccountDetails import com.divinelink.core.model.jellyseerr.JellyseerrLoginMethod import com.divinelink.core.model.jellyseerr.JellyseerrLoginParams -import com.divinelink.core.commons.domain.DispatcherProvider import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.last @@ -14,6 +15,7 @@ import kotlinx.coroutines.flow.last open class LoginJellyseerrUseCase( private val repository: JellyseerrRepository, private val storage: PreferenceStorage, + private val encryptedStorage: EncryptedStorage, val dispatcher: DispatcherProvider, ) : FlowUseCase(dispatcher.io) { @@ -38,6 +40,7 @@ open class LoginJellyseerrUseCase( storage.setJellyseerrAccount(parameters.username.value) storage.setJellyseerrAddress(parameters.address) storage.setJellyseerrSignInMethod(parameters.signInMethod.name) + encryptedStorage.setJellyseerrPassword(parameters.password.value) repository.insertJellyseerrAccountDetails(accountDetails) emit(Result.success(accountDetails)) diff --git a/core/domain/src/main/kotlin/com/divinelink/core/domain/jellyseerr/LogoutJellyseerrUseCase.kt b/core/domain/src/main/kotlin/com/divinelink/core/domain/jellyseerr/LogoutJellyseerrUseCase.kt index e77a0d59..cdcdbfa8 100644 --- a/core/domain/src/main/kotlin/com/divinelink/core/domain/jellyseerr/LogoutJellyseerrUseCase.kt +++ b/core/domain/src/main/kotlin/com/divinelink/core/domain/jellyseerr/LogoutJellyseerrUseCase.kt @@ -1,13 +1,14 @@ package com.divinelink.core.domain.jellyseerr +import com.divinelink.core.commons.domain.DispatcherProvider import com.divinelink.core.commons.domain.FlowUseCase import com.divinelink.core.data.jellyseerr.repository.JellyseerrRepository import com.divinelink.core.datastore.SessionStorage -import com.divinelink.core.commons.domain.DispatcherProvider import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.last +import kotlinx.coroutines.flow.onCompletion open class LogoutJellyseerrUseCase( private val repository: JellyseerrRepository, @@ -23,16 +24,17 @@ open class LogoutJellyseerrUseCase( } emit( - repository.logout(address).last().fold( - onSuccess = { + repository + .logout(address) + .onCompletion { sessionStorage.clearJellyseerrSession() repository.clearJellyseerrAccountDetails() - Result.success(address) - }, - onFailure = { - Result.failure(it) - }, - ), + } + .last() + .fold( + onSuccess = { Result.success(address) }, + onFailure = { Result.failure(it) }, + ), ) } } diff --git a/core/domain/src/test/kotlin/com/divinelink/core/domain/jellyseerr/LoginJellyseerrUseCaseTest.kt b/core/domain/src/test/kotlin/com/divinelink/core/domain/jellyseerr/LoginJellyseerrUseCaseTest.kt index ce7e2b5a..821b818d 100644 --- a/core/domain/src/test/kotlin/com/divinelink/core/domain/jellyseerr/LoginJellyseerrUseCaseTest.kt +++ b/core/domain/src/test/kotlin/com/divinelink/core/domain/jellyseerr/LoginJellyseerrUseCaseTest.kt @@ -1,5 +1,6 @@ package com.divinelink.core.domain.jellyseerr +import com.divinelink.core.datastore.EncryptedStorage import com.divinelink.core.datastore.PreferenceStorage import com.divinelink.core.model.Password import com.divinelink.core.model.Username @@ -8,6 +9,7 @@ import com.divinelink.core.model.jellyseerr.JellyseerrLoginParams import com.divinelink.core.testing.MainDispatcherRule import com.divinelink.core.testing.factories.model.jellyseerr.JellyseerrAccountDetailsFactory import com.divinelink.core.testing.repository.TestJellyseerrRepository +import com.divinelink.core.testing.storage.FakeEncryptedPreferenceStorage import com.divinelink.core.testing.storage.FakePreferenceStorage import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.flow.first @@ -18,6 +20,7 @@ import kotlin.test.Test class LoginJellyseerrUseCaseTest { private lateinit var preferenceStorage: PreferenceStorage + private lateinit var encryptedStorage: EncryptedStorage private val repository = TestJellyseerrRepository() @@ -28,11 +31,13 @@ class LoginJellyseerrUseCaseTest { @Test fun `test loginJellyseerr with null parameters throws exception`() = runTest { preferenceStorage = FakePreferenceStorage() + encryptedStorage = FakeEncryptedPreferenceStorage() val useCase = LoginJellyseerrUseCase( repository = repository.mock, storage = preferenceStorage, dispatcher = testDispatcher, + encryptedStorage = encryptedStorage, ) useCase.invoke(null).collect { @@ -44,6 +49,7 @@ class LoginJellyseerrUseCaseTest { @Test fun `test loginJellyseerr with Jellyfin login method`() = runTest { preferenceStorage = FakePreferenceStorage() + encryptedStorage = FakeEncryptedPreferenceStorage() repository.mockSignInWithJellyfin(Result.success(JellyseerrAccountDetailsFactory.jellyfin())) @@ -51,8 +57,11 @@ class LoginJellyseerrUseCaseTest { repository = repository.mock, storage = preferenceStorage, dispatcher = testDispatcher, + encryptedStorage = encryptedStorage, ) + assertThat(encryptedStorage.jellyseerrPassword).isNull() + useCase.invoke( JellyseerrLoginParams( username = Username("jellyfinUsername"), @@ -68,12 +77,14 @@ class LoginJellyseerrUseCaseTest { assertThat( preferenceStorage.jellyseerrSignInMethod.first(), ).isEqualTo(JellyseerrLoginMethod.JELLYFIN.name) + assertThat(encryptedStorage.jellyseerrPassword).isEqualTo("password") } } @Test fun `test loginJellyseerr with Jellyseerr login method`() = runTest { preferenceStorage = FakePreferenceStorage() + encryptedStorage = FakeEncryptedPreferenceStorage() repository.mockSignInWithJellyseerr( Result.success(JellyseerrAccountDetailsFactory.jellyseerr()), @@ -83,8 +94,11 @@ class LoginJellyseerrUseCaseTest { repository = repository.mock, storage = preferenceStorage, dispatcher = testDispatcher, + encryptedStorage = encryptedStorage, ) + assertThat(encryptedStorage.jellyseerrPassword).isNull() + useCase.invoke( JellyseerrLoginParams( username = Username("jellyseerrUsername"), @@ -100,12 +114,14 @@ class LoginJellyseerrUseCaseTest { assertThat( preferenceStorage.jellyseerrSignInMethod.first(), ).isEqualTo(JellyseerrLoginMethod.JELLYSEERR.name) + assertThat(encryptedStorage.jellyseerrPassword).isEqualTo("password") } } @Test fun `test loginJellyseerr with Jellyseerr login method and error`() = runTest { preferenceStorage = FakePreferenceStorage() + encryptedStorage = FakeEncryptedPreferenceStorage() repository.mockSignInWithJellyseerr(Result.failure(Exception("error"))) @@ -113,6 +129,7 @@ class LoginJellyseerrUseCaseTest { repository = repository.mock, storage = preferenceStorage, dispatcher = testDispatcher, + encryptedStorage = encryptedStorage, ) useCase.invoke( @@ -130,6 +147,7 @@ class LoginJellyseerrUseCaseTest { @Test fun `test loginJellyseerr with Jellyfin login method and error`() = runTest { preferenceStorage = FakePreferenceStorage() + encryptedStorage = FakeEncryptedPreferenceStorage() repository.mockSignInWithJellyfin(Result.failure(Exception("error"))) @@ -137,6 +155,7 @@ class LoginJellyseerrUseCaseTest { repository = repository.mock, storage = preferenceStorage, dispatcher = testDispatcher, + encryptedStorage = encryptedStorage, ) useCase.invoke( diff --git a/core/model/src/main/kotlin/com/divinelink/core/model/exception/JellyseerrUnauthorizedException.kt b/core/model/src/main/kotlin/com/divinelink/core/model/exception/JellyseerrUnauthorizedException.kt new file mode 100644 index 00000000..0c9f9d11 --- /dev/null +++ b/core/model/src/main/kotlin/com/divinelink/core/model/exception/JellyseerrUnauthorizedException.kt @@ -0,0 +1,5 @@ +package com.divinelink.core.model.exception + +class JellyseerrUnauthorizedException : Exception() + +class JellyseerrInvalidCredentials : Exception() diff --git a/core/model/src/main/kotlin/com/divinelink/core/model/jellyseerr/JellyseerrLoginMethod.kt b/core/model/src/main/kotlin/com/divinelink/core/model/jellyseerr/JellyseerrLoginMethod.kt index 9df926a3..e3a2a704 100644 --- a/core/model/src/main/kotlin/com/divinelink/core/model/jellyseerr/JellyseerrLoginMethod.kt +++ b/core/model/src/main/kotlin/com/divinelink/core/model/jellyseerr/JellyseerrLoginMethod.kt @@ -1,6 +1,12 @@ +@file:Suppress("ktlint:standard:trailing-comma-on-declaration-site") + package com.divinelink.core.model.jellyseerr -enum class JellyseerrLoginMethod { - JELLYFIN, - JELLYSEERR, +enum class JellyseerrLoginMethod(val endpoint: String) { + JELLYFIN("jellyfin"), + JELLYSEERR("local"); + + companion object { + fun from(name: String): JellyseerrLoginMethod? = entries.find { it.name == name } + } } diff --git a/core/network/src/main/kotlin/com/divinelink/core/network/client/JellyseerrRestClient.kt b/core/network/src/main/kotlin/com/divinelink/core/network/client/JellyseerrRestClient.kt index 7122e447..7b157454 100644 --- a/core/network/src/main/kotlin/com/divinelink/core/network/client/JellyseerrRestClient.kt +++ b/core/network/src/main/kotlin/com/divinelink/core/network/client/JellyseerrRestClient.kt @@ -1,19 +1,88 @@ package com.divinelink.core.network.client import com.divinelink.core.datastore.EncryptedStorage +import com.divinelink.core.datastore.PreferenceStorage +import com.divinelink.core.model.exception.JellyseerrInvalidCredentials +import com.divinelink.core.model.exception.JellyseerrUnauthorizedException +import com.divinelink.core.model.jellyseerr.JellyseerrLoginMethod +import com.divinelink.core.network.jellyseerr.model.JellyfinLoginResponseApi +import com.divinelink.core.network.jellyseerr.model.JellyseerrLoginRequestBodyApi +import com.divinelink.core.network.jellyseerr.model.toRequestBodyApi import io.ktor.client.HttpClient import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.plugins.HttpRequestRetry +import io.ktor.client.plugins.HttpResponseValidator import io.ktor.client.plugins.cookies.HttpCookies +import io.ktor.client.statement.HttpReceivePipeline +import io.ktor.client.statement.request +import io.ktor.http.HttpStatusCode +import io.ktor.http.Url +import kotlinx.coroutines.flow.first class JellyseerrRestClient( engine: HttpClientEngine, private val encryptedStorage: EncryptedStorage, + private val datastore: PreferenceStorage, ) { - val client: HttpClient = ktorClient(engine).config { - install(HttpCookies) { - storage = PersistentCookieStorage(encryptedStorage) + companion object { + const val AUTH_ENDPOINT = "/api/v1/auth/" + } + + val client: HttpClient = ktorClient(engine) + .config { + install(HttpCookies) { + storage = PersistentCookieStorage(encryptedStorage) + } + + HttpResponseValidator { + validateResponse { response -> + if (response.status == HttpStatusCode.Unauthorized) { + throw JellyseerrUnauthorizedException() + } + } + } + + install(HttpRequestRetry) { + retryIf(maxRetries = 1) { _, response -> + val isAuthEndpoint = response.request.url.isAuthEndpoint() + + when { + response.status == HttpStatusCode.Unauthorized && !isAuthEndpoint -> true + else -> false + } + } + } + } + .apply { + receivePipeline.intercept(HttpReceivePipeline.Before) { + val isAuthEndpoint = subject.request.url.isAuthEndpoint() + + if (subject.status == HttpStatusCode.Unauthorized && !isAuthEndpoint) { + reAuthenticate() + } + } } + + private suspend fun reAuthenticate() { + val account = datastore.jellyseerrAccount.first() + val password = encryptedStorage.jellyseerrPassword + val address = datastore.jellyseerrAddress.first() + val signInMethod = datastore.jellyseerrSignInMethod.first() + + if (account == null || password == null || address == null || signInMethod == null) { + throw JellyseerrInvalidCredentials() + } + + val loginMethod = JellyseerrLoginMethod.from(signInMethod) + ?: throw JellyseerrInvalidCredentials() + + val body = loginMethod.toRequestBodyApi(account, password) + + post( + url = "$address$AUTH_ENDPOINT${loginMethod.endpoint}", + body = body, + ) } suspend inline fun get(url: String): T = client.get(url) @@ -26,4 +95,6 @@ class JellyseerrRestClient( fun close() { client.close() } + + private fun Url.isAuthEndpoint(): Boolean = encodedPath.contains(AUTH_ENDPOINT) } diff --git a/core/network/src/main/kotlin/com/divinelink/core/network/client/PersistentCookieStorage.kt b/core/network/src/main/kotlin/com/divinelink/core/network/client/PersistentCookieStorage.kt index 856da0ca..56eaeb6e 100644 --- a/core/network/src/main/kotlin/com/divinelink/core/network/client/PersistentCookieStorage.kt +++ b/core/network/src/main/kotlin/com/divinelink/core/network/client/PersistentCookieStorage.kt @@ -10,7 +10,6 @@ import io.ktor.util.date.GMTDate /** * A [CookiesStorage] implementation that stores cookies in an encrypted storage. */ - class PersistentCookieStorage(val storage: EncryptedStorage) : CookiesStorage { override suspend fun get(requestUrl: Url): List { val cookies = mutableListOf() diff --git a/core/network/src/main/kotlin/com/divinelink/core/network/jellyseerr/model/JellyfinLoginRequestBodyApi.kt b/core/network/src/main/kotlin/com/divinelink/core/network/jellyseerr/model/JellyfinLoginRequestBodyApi.kt deleted file mode 100644 index 50a4acf5..00000000 --- a/core/network/src/main/kotlin/com/divinelink/core/network/jellyseerr/model/JellyfinLoginRequestBodyApi.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.divinelink.core.network.jellyseerr.model - -import kotlinx.serialization.Serializable - -@Serializable -data class JellyfinLoginRequestBodyApi( - val username: String, - val password: String, -) diff --git a/core/network/src/main/kotlin/com/divinelink/core/network/jellyseerr/model/JellyseerrLoginRequestBodyApi.kt b/core/network/src/main/kotlin/com/divinelink/core/network/jellyseerr/model/JellyseerrLoginRequestBodyApi.kt index bcd43232..7aa73f00 100644 --- a/core/network/src/main/kotlin/com/divinelink/core/network/jellyseerr/model/JellyseerrLoginRequestBodyApi.kt +++ b/core/network/src/main/kotlin/com/divinelink/core/network/jellyseerr/model/JellyseerrLoginRequestBodyApi.kt @@ -1,9 +1,32 @@ package com.divinelink.core.network.jellyseerr.model +import com.divinelink.core.model.jellyseerr.JellyseerrLoginMethod import kotlinx.serialization.Serializable -@Serializable -data class JellyseerrLoginRequestBodyApi( - val email: String, - val password: String, -) +sealed interface JellyseerrLoginRequestBodyApi { + @Serializable + data class Jellyfin( + val username: String, + val password: String, + ) : JellyseerrLoginRequestBodyApi + + @Serializable + data class Jellyseerr( + val email: String, + val password: String, + ) : JellyseerrLoginRequestBodyApi +} + +fun JellyseerrLoginMethod.toRequestBodyApi( + username: String, + password: String, +): JellyseerrLoginRequestBodyApi = when (this) { + JellyseerrLoginMethod.JELLYFIN -> JellyseerrLoginRequestBodyApi.Jellyfin( + username = username, + password = password, + ) + JellyseerrLoginMethod.JELLYSEERR -> JellyseerrLoginRequestBodyApi.Jellyseerr( + email = username, + password = password, + ) +} diff --git a/core/network/src/main/kotlin/com/divinelink/core/network/jellyseerr/service/ProdJellyseerrService.kt b/core/network/src/main/kotlin/com/divinelink/core/network/jellyseerr/service/ProdJellyseerrService.kt index 083a36cb..49420cbe 100644 --- a/core/network/src/main/kotlin/com/divinelink/core/network/jellyseerr/service/ProdJellyseerrService.kt +++ b/core/network/src/main/kotlin/com/divinelink/core/network/jellyseerr/service/ProdJellyseerrService.kt @@ -2,7 +2,7 @@ package com.divinelink.core.network.jellyseerr.service import com.divinelink.core.model.jellyseerr.JellyseerrLoginData import com.divinelink.core.network.client.JellyseerrRestClient -import com.divinelink.core.network.jellyseerr.model.JellyfinLoginRequestBodyApi +import com.divinelink.core.network.client.JellyseerrRestClient.Companion.AUTH_ENDPOINT import com.divinelink.core.network.jellyseerr.model.JellyfinLoginResponseApi import com.divinelink.core.network.jellyseerr.model.JellyseerrLoginRequestBodyApi import com.divinelink.core.network.jellyseerr.model.JellyseerrRequestMediaBodyApi @@ -15,11 +15,11 @@ class ProdJellyseerrService(private val restClient: JellyseerrRestClient) : Jell override suspend fun signInWithJellyfin( jellyfinLogin: JellyseerrLoginData, ): Flow = flow { - val url = "${jellyfinLogin.address}/api/v1/auth/jellyfin" + val url = "${jellyfinLogin.address}${AUTH_ENDPOINT}jellyfin" - val response = restClient.post( + val response = restClient.post( url = url, - body = JellyfinLoginRequestBodyApi( + body = JellyseerrLoginRequestBodyApi.Jellyfin( username = jellyfinLogin.username.value, password = jellyfinLogin.password.value, ), @@ -31,11 +31,11 @@ class ProdJellyseerrService(private val restClient: JellyseerrRestClient) : Jell override suspend fun signInWithJellyseerr( jellyseerrLogin: JellyseerrLoginData, ): Flow = flow { - val url = "${jellyseerrLogin.address}/api/v1/auth/local" + val url = "${jellyseerrLogin.address}${AUTH_ENDPOINT}local" val response = restClient.post( url = url, - body = JellyseerrLoginRequestBodyApi( + body = JellyseerrLoginRequestBodyApi.Jellyseerr( email = jellyseerrLogin.username.value, password = jellyseerrLogin.password.value, ), diff --git a/core/network/src/test/kotlin/com/divinelink/core/network/client/JellyseerrRestClientTest.kt b/core/network/src/test/kotlin/com/divinelink/core/network/client/JellyseerrRestClientTest.kt new file mode 100644 index 00000000..336b6ebe --- /dev/null +++ b/core/network/src/test/kotlin/com/divinelink/core/network/client/JellyseerrRestClientTest.kt @@ -0,0 +1,313 @@ +package com.divinelink.core.network.client + +import com.divinelink.core.datastore.EncryptedStorage +import com.divinelink.core.datastore.PreferenceStorage +import com.divinelink.core.model.exception.JellyseerrInvalidCredentials +import com.divinelink.core.model.exception.JellyseerrUnauthorizedException +import com.divinelink.core.model.jellyseerr.JellyseerrLoginMethod +import com.divinelink.core.network.jellyseerr.model.JellyseerrRequestMediaBodyApi +import com.divinelink.core.network.jellyseerr.model.JellyseerrResponseBodyApi +import com.divinelink.core.testing.factories.api.jellyseerr.JellyseerrRequestMediaBodyApiFactory +import com.divinelink.core.testing.storage.FakeEncryptedPreferenceStorage +import com.divinelink.core.testing.storage.FakePreferenceStorage +import com.google.common.truth.Truth.assertThat +import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class JellyseerrRestClientTest { + + private lateinit var client: JellyseerrRestClient + private lateinit var encryptedStorage: EncryptedStorage + private lateinit var datastore: PreferenceStorage + private lateinit var engine: HttpClientEngine + + private val address = "http://localhost:8080" + + @Test + fun `test unauthorized request triggers reAuthentication`() = runTest { + val requestHistory = mutableListOf>() + + val expectedRequests = listOf( + "http://localhost:8080/api/v1/request" to HttpStatusCode.Unauthorized, + "http://localhost:8080/api/v1/auth/local" to HttpStatusCode.OK, + "http://localhost:8080/api/v1/request" to HttpStatusCode.OK, + ) + + var requestCount = 0 + + engine = MockEngine { request -> + when (request.method) { + HttpMethod.Get -> respond( + content = "Failed to authenticate", + status = HttpStatusCode.Unauthorized, + ) + HttpMethod.Post -> { + val response = if (requestCount == 0) { + respond( + content = "Failed to authenticate", + status = HttpStatusCode.Unauthorized, + ) + } else { + respond( + content = """{"success": true}""", + status = HttpStatusCode.OK, + ) + } + requestCount++ + response.also { requestHistory.add(request.url.toString() to response.statusCode) } + } + else -> error("Unexpected request method: ${request.method}") + } + } + + encryptedStorage = FakeEncryptedPreferenceStorage( + jellyseerrPassword = "testPassword", + ) + datastore = FakePreferenceStorage( + jellyseerrAccount = "testAccount", + jellyseerrAddress = address, + jellyseerrSignInMethod = JellyseerrLoginMethod.JELLYSEERR.name, + ) + + client = JellyseerrRestClient( + engine = engine, + encryptedStorage = encryptedStorage, + datastore = datastore, + ) + + // Initial request + client.post( + url = "http://localhost:8080/api/v1/request", + body = JellyseerrRequestMediaBodyApiFactory.movie(), + ) + + assertThat(requestHistory.size).isEqualTo(3) + assertThat(requestHistory).isEqualTo(expectedRequests) + } + + @Test + fun `test reAuthentication with invalidCredentials throws UnauthorizedException`() = runTest { + val requestHistory = mutableListOf>() + + val expectedRequests = listOf( + "http://localhost:8080/api/v1/request" to HttpStatusCode.Unauthorized, + "http://localhost:8080/api/v1/auth/local" to HttpStatusCode.Unauthorized, + "http://localhost:8080/api/v1/request" to HttpStatusCode.Unauthorized, + "http://localhost:8080/api/v1/auth/local" to HttpStatusCode.Unauthorized, + ) + + engine = MockEngine { request -> + when (request.method) { + HttpMethod.Get -> respond( + content = "Failed to authenticate", + status = HttpStatusCode.Unauthorized, + ) + HttpMethod.Post -> { + respond( + content = "Failed to authenticate", + status = HttpStatusCode.Unauthorized, + ).also { + requestHistory.add(request.url.toString() to it.statusCode) + } + } + else -> error("Unexpected request method: ${request.method}") + } + } + + encryptedStorage = FakeEncryptedPreferenceStorage( + jellyseerrPassword = "testPassword", + ) + datastore = FakePreferenceStorage( + jellyseerrAccount = "testAccount", + jellyseerrAddress = address, + jellyseerrSignInMethod = JellyseerrLoginMethod.JELLYSEERR.name, + ) + + client = JellyseerrRestClient( + engine = engine, + encryptedStorage = encryptedStorage, + datastore = datastore, + ) + + assertFailsWith { + client.post( + url = "http://localhost:8080/api/v1/request", + body = JellyseerrRequestMediaBodyApiFactory.movie(), + ) + } + + assertThat(requestHistory.size).isEqualTo(4) + assertThat(requestHistory).isEqualTo(expectedRequests) + } + + @Test + fun `test reAuthentication without password throws InvalidCredentials`() = runTest { + engine = MockEngine { + respond( + content = "Failed to authenticate", + status = HttpStatusCode.Unauthorized, + ) + } + + encryptedStorage = FakeEncryptedPreferenceStorage( + jellyseerrPassword = null, + ) + + datastore = FakePreferenceStorage( + jellyseerrAccount = "testAccount", + jellyseerrAddress = address, + jellyseerrSignInMethod = JellyseerrLoginMethod.JELLYSEERR.name, + ) + + client = JellyseerrRestClient( + engine = engine, + encryptedStorage = encryptedStorage, + datastore = datastore, + ) + + assertFailsWith { + client.post( + url = "http://localhost:8080/api/v1/request", + body = JellyseerrRequestMediaBodyApiFactory.movie(), + ) + } + } + + @Test + fun `test reAuthentication without account throws InvalidCredentials`() = runTest { + engine = MockEngine { + respond( + content = "Failed to authenticate", + status = HttpStatusCode.Unauthorized, + ) + } + + encryptedStorage = FakeEncryptedPreferenceStorage( + jellyseerrPassword = "testPassword", + ) + + datastore = FakePreferenceStorage( + jellyseerrAccount = null, + jellyseerrAddress = address, + jellyseerrSignInMethod = JellyseerrLoginMethod.JELLYSEERR.name, + ) + + client = JellyseerrRestClient( + engine = engine, + encryptedStorage = encryptedStorage, + datastore = datastore, + ) + + assertFailsWith { + client.post( + url = "http://localhost:8080/api/v1/request", + body = JellyseerrRequestMediaBodyApiFactory.movie(), + ) + } + } + + @Test + fun `test reAuthentication without address throws InvalidCredentials`() = runTest { + engine = MockEngine { + respond( + content = "Failed to authenticate", + status = HttpStatusCode.Unauthorized, + ) + } + + encryptedStorage = FakeEncryptedPreferenceStorage( + jellyseerrPassword = "testPassword", + ) + + datastore = FakePreferenceStorage( + jellyseerrAccount = "testAccount", + jellyseerrAddress = null, + jellyseerrSignInMethod = JellyseerrLoginMethod.JELLYSEERR.name, + ) + + client = JellyseerrRestClient( + engine = engine, + encryptedStorage = encryptedStorage, + datastore = datastore, + ) + + assertFailsWith { + client.post( + url = "http://localhost:8080/api/v1/request", + body = JellyseerrRequestMediaBodyApiFactory.movie(), + ) + } + } + + @Test + fun `test reAuthentication without signInMethod throws InvalidCredentials`() = runTest { + engine = MockEngine { + respond( + content = "Failed to authenticate", + status = HttpStatusCode.Unauthorized, + ) + } + + encryptedStorage = FakeEncryptedPreferenceStorage( + jellyseerrPassword = "testPassword", + ) + + datastore = FakePreferenceStorage( + jellyseerrAccount = "testAccount", + jellyseerrAddress = address, + jellyseerrSignInMethod = null, + ) + + client = JellyseerrRestClient( + engine = engine, + encryptedStorage = encryptedStorage, + datastore = datastore, + ) + + assertFailsWith { + client.post( + url = "http://localhost:8080/api/v1/request", + body = JellyseerrRequestMediaBodyApiFactory.movie(), + ) + } + } + + @Test + fun `test reAuthentication with invalid sign in method throws InvalidCredentials`() = runTest { + engine = MockEngine { + respond( + content = "Failed to authenticate", + status = HttpStatusCode.Unauthorized, + ) + } + + encryptedStorage = FakeEncryptedPreferenceStorage( + jellyseerrPassword = "testPassword", + ) + + datastore = FakePreferenceStorage( + jellyseerrAccount = "testAccount", + jellyseerrAddress = address, + jellyseerrSignInMethod = "invalid method", + ) + + client = JellyseerrRestClient( + engine = engine, + encryptedStorage = encryptedStorage, + datastore = datastore, + ) + + assertFailsWith { + client.post( + url = "http://localhost:8080/api/v1/request", + body = JellyseerrRequestMediaBodyApiFactory.movie(), + ) + } + } +} diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index 5f1fa8cb..1db5ef10 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -35,10 +35,9 @@ dependencies { implementation(libs.kotlinx.datetime) - implementation(libs.ktor.client.mock) + api(libs.ktor.client.mock) api(libs.koin.test) -// implementation(libs.koin.android) api(libs.datastore) api(libs.datastore.core) diff --git a/core/testing/src/main/kotlin/com/divinelink/core/testing/network/MockEngine.kt b/core/testing/src/main/kotlin/com/divinelink/core/testing/network/MockEngine.kt index 7f6f1bcb..552942b0 100644 --- a/core/testing/src/main/kotlin/com/divinelink/core/testing/network/MockEngine.kt +++ b/core/testing/src/main/kotlin/com/divinelink/core/testing/network/MockEngine.kt @@ -2,14 +2,19 @@ package com.divinelink.core.testing.network import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond +import io.ktor.http.Headers import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf -fun MockEngine(content: String) = MockEngine { +fun MockEngine( + content: String, + status: HttpStatusCode = HttpStatusCode.OK, + headers: Headers = headersOf(HttpHeaders.ContentType, "application/json"), +) = MockEngine { respond( content = content, - status = HttpStatusCode.OK, - headers = headersOf(HttpHeaders.ContentType, "application/json"), + status = status, + headers = headers, ) } diff --git a/core/testing/src/main/kotlin/com/divinelink/core/testing/storage/FakeEncryptedPreferenceStorage.kt b/core/testing/src/main/kotlin/com/divinelink/core/testing/storage/FakeEncryptedPreferenceStorage.kt index c0fc6d22..d0e31790 100644 --- a/core/testing/src/main/kotlin/com/divinelink/core/testing/storage/FakeEncryptedPreferenceStorage.kt +++ b/core/testing/src/main/kotlin/com/divinelink/core/testing/storage/FakeEncryptedPreferenceStorage.kt @@ -6,6 +6,7 @@ open class FakeEncryptedPreferenceStorage( override var tmdbAuthToken: String = "", override var sessionId: String? = null, override var jellyseerrAuthCookie: String? = null, + override var jellyseerrPassword: String? = null, ) : EncryptedStorage { override suspend fun setTmdbAuthToken(key: String) { @@ -27,4 +28,12 @@ open class FakeEncryptedPreferenceStorage( override suspend fun setJellyseerrAuthCookie(cookie: String) { this.jellyseerrAuthCookie = cookie } + + override suspend fun clearJellyseerrPassword() { + this.jellyseerrPassword = null + } + + override suspend fun setJellyseerrPassword(password: String) { + this.jellyseerrPassword = password + } } diff --git a/core/testing/src/main/kotlin/com/divinelink/core/testing/usecase/FakeLogoutJellyseerrUseCase.kt b/core/testing/src/main/kotlin/com/divinelink/core/testing/usecase/FakeLogoutJellyseerrUseCase.kt index 0c36ae92..be9d1109 100644 --- a/core/testing/src/main/kotlin/com/divinelink/core/testing/usecase/FakeLogoutJellyseerrUseCase.kt +++ b/core/testing/src/main/kotlin/com/divinelink/core/testing/usecase/FakeLogoutJellyseerrUseCase.kt @@ -15,12 +15,8 @@ class FakeLogoutJellyseerrUseCase { mockFailure() } - private fun mockFailure() { - whenever( - mock.invoke(any()), - ).thenReturn( - flowOf(Result.failure(Exception())), - ) + fun mockFailure(error: Throwable = Exception()) { + whenever(mock.invoke(any())).thenReturn(flowOf(Result.failure(error))) } fun mockSuccess(response: Flow>) { diff --git a/core/testing/src/main/kotlin/com/divinelink/core/testing/usecase/FakeRequestMediaUseCase.kt b/core/testing/src/main/kotlin/com/divinelink/core/testing/usecase/FakeRequestMediaUseCase.kt index af89a111..62a4df72 100644 --- a/core/testing/src/main/kotlin/com/divinelink/core/testing/usecase/FakeRequestMediaUseCase.kt +++ b/core/testing/src/main/kotlin/com/divinelink/core/testing/usecase/FakeRequestMediaUseCase.kt @@ -3,6 +3,7 @@ package com.divinelink.core.testing.usecase import com.divinelink.core.domain.jellyseerr.RequestMediaUseCase import com.divinelink.core.model.jellyseerr.request.JellyseerrMediaRequest import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @@ -14,4 +15,12 @@ class FakeRequestMediaUseCase { fun mockSuccess(response: Flow>) { whenever(mock.invoke(any())).thenReturn(response) } + + fun mockFailure(throwable: Throwable) { + whenever( + mock.invoke(any()), + ).thenReturn( + flowOf(Result.failure(throwable)), + ) + } } diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index afc4ce48..e1d48d2e 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -57,6 +57,8 @@ You are offline Please connect to the internet and try again. + Your Jellyseerr session has expired. Please login again. + Navigate Up Button Movie image diff --git a/feature/details/src/main/kotlin/com/divinelink/feature/details/media/ui/DetailsContent.kt b/feature/details/src/main/kotlin/com/divinelink/feature/details/media/ui/DetailsContent.kt index 2116560e..5e5df3f8 100644 --- a/feature/details/src/main/kotlin/com/divinelink/feature/details/media/ui/DetailsContent.kt +++ b/feature/details/src/main/kotlin/com/divinelink/feature/details/media/ui/DetailsContent.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize diff --git a/feature/details/src/main/kotlin/com/divinelink/feature/details/media/ui/DetailsViewModel.kt b/feature/details/src/main/kotlin/com/divinelink/feature/details/media/ui/DetailsViewModel.kt index 7f30ce93..a51d5877 100644 --- a/feature/details/src/main/kotlin/com/divinelink/feature/details/media/ui/DetailsViewModel.kt +++ b/feature/details/src/main/kotlin/com/divinelink/feature/details/media/ui/DetailsViewModel.kt @@ -1,5 +1,6 @@ package com.divinelink.feature.details.media.ui +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarResult import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel @@ -333,29 +334,43 @@ class DetailsViewModel( .onEach { result -> result.onSuccess { response -> response.message?.let { message -> - setSnackbarMessage( - UIText.StringText(message), - ) + setSnackbarMessage(SnackbarMessage.from(text = UIText.StringText(message))) } ?: run { setSnackbarMessage( - UIText.ResourceText( - R.string.feature_details_jellyseerr_success_media_request, - viewState.value.mediaDetails?.title ?: "", + SnackbarMessage.from( + UIText.ResourceText( + R.string.feature_details_jellyseerr_success_media_request, + viewState.value.mediaDetails?.title ?: "", + ), ), ) } }.onFailure { ErrorHandler.create(it) { + on(403) { + setSnackbarMessage( + SnackbarMessage.from( + text = UIText.ResourceText(uiR.string.core_ui_jellyseerr_session_expired), + actionLabelText = UIText.ResourceText(uiR.string.core_ui_login), + duration = SnackbarDuration.Long, + onSnackbarResult = ::navigateToLogin, + ), + ) + } on(409) { setSnackbarMessage( - text = UIText.ResourceText(R.string.feature_details_jellyseerr_request_exists), + SnackbarMessage.from( + text = UIText.ResourceText(R.string.feature_details_jellyseerr_request_exists), + ), ) } otherwise { setSnackbarMessage( - text = UIText.ResourceText( - R.string.feature_details_jellyseerr_request_failed, - viewState.value.mediaDetails?.title ?: "", + SnackbarMessage.from( + text = UIText.ResourceText( + R.string.feature_details_jellyseerr_request_failed, + viewState.value.mediaDetails?.title ?: "", + ), ), ) } @@ -376,9 +391,11 @@ class DetailsViewModel( } } - private fun setSnackbarMessage(text: UIText) { + private fun setSnackbarMessage(snackbarMessage: SnackbarMessage) { _viewState.update { viewState -> - viewState.copy(snackbarMessage = SnackbarMessage.from(text)) + viewState.copy( + snackbarMessage = snackbarMessage, + ) } } diff --git a/feature/settings/src/main/kotlin/com/divinelink/feature/settings/app/account/jellyseerr/JellyseerrSettingsViewModel.kt b/feature/settings/src/main/kotlin/com/divinelink/feature/settings/app/account/jellyseerr/JellyseerrSettingsViewModel.kt index e75f0f64..a0aa17df 100644 --- a/feature/settings/src/main/kotlin/com/divinelink/feature/settings/app/account/jellyseerr/JellyseerrSettingsViewModel.kt +++ b/feature/settings/src/main/kotlin/com/divinelink/feature/settings/app/account/jellyseerr/JellyseerrSettingsViewModel.kt @@ -9,6 +9,7 @@ import com.divinelink.core.domain.jellyseerr.LoginJellyseerrUseCase import com.divinelink.core.domain.jellyseerr.LogoutJellyseerrUseCase import com.divinelink.core.model.Password import com.divinelink.core.model.Username +import com.divinelink.core.model.exception.JellyseerrUnauthorizedException import com.divinelink.core.model.jellyseerr.JellyseerrLoginMethod import com.divinelink.core.model.jellyseerr.JellyseerrState import com.divinelink.core.model.jellyseerr.loginParams @@ -139,8 +140,22 @@ class JellyseerrSettingsViewModel( ), ) } - }.onFailure { - _uiState.setSnackbarMessage(UIText.ResourceText(uiR.string.core_ui_error_retry)) + }.onFailure { throwable -> + ErrorHandler.create(throwable) { + on { + _uiState.update { + it.copy( + jellyseerrState = JellyseerrState.Initial( + address = "", + isLoading = false, + ), + ) + } + } + otherwise { + _uiState.setSnackbarMessage(UIText.ResourceText(uiR.string.core_ui_error_retry)) + } + } } } .launchIn(viewModelScope) diff --git a/feature/settings/src/test/kotlin/com/divinelink/feature/settings/app/account/jellyseerr/JellyseerrSettingsViewModelTest.kt b/feature/settings/src/test/kotlin/com/divinelink/feature/settings/app/account/jellyseerr/JellyseerrSettingsViewModelTest.kt index 9ce783d4..8fe100ba 100644 --- a/feature/settings/src/test/kotlin/com/divinelink/feature/settings/app/account/jellyseerr/JellyseerrSettingsViewModelTest.kt +++ b/feature/settings/src/test/kotlin/com/divinelink/feature/settings/app/account/jellyseerr/JellyseerrSettingsViewModelTest.kt @@ -3,19 +3,21 @@ package com.divinelink.feature.settings.app.account.jellyseerr import com.divinelink.core.commons.exception.InvalidStatusException import com.divinelink.core.model.Password import com.divinelink.core.model.Username +import com.divinelink.core.model.exception.JellyseerrUnauthorizedException import com.divinelink.core.model.jellyseerr.JellyseerrLoginData import com.divinelink.core.model.jellyseerr.JellyseerrLoginMethod import com.divinelink.core.model.jellyseerr.JellyseerrState import com.divinelink.core.testing.MainDispatcherRule import com.divinelink.core.testing.assertUiState +import com.divinelink.core.testing.factories.model.jellyseerr.JellyseerrAccountDetailsFactory import com.divinelink.core.ui.UIText import com.divinelink.core.ui.snackbar.SnackbarMessage import com.divinelink.feature.settings.R import kotlinx.coroutines.test.runTest import org.junit.Rule -import kotlin.test.Test import java.net.ConnectException import java.net.UnknownHostException +import kotlin.test.Test import com.divinelink.core.ui.R as uiR class JellyseerrSettingsViewModelTest { @@ -149,6 +151,63 @@ class JellyseerrSettingsViewModelTest { ) } + @Test + fun `test logout with UnauthorizedException returns initial state`() = runTest { + testRobot + .mockJellyseerrAccountDetailsResponse( + Result.success(JellyseerrAccountDetailsFactory.jellyseerr()), + ) + .mockLogoutJellyseerrResponse(Result.failure(JellyseerrUnauthorizedException())) + .buildViewModel() + .assertUiState( + createUiState( + jellyseerrState = JellyseerrState.LoggedIn( + accountDetails = JellyseerrAccountDetailsFactory.jellyseerr(), + isLoading = false, + ), + ), + ) + .onLogoutJellyseerr() + .assertUiState( + createUiState( + jellyseerrState = JellyseerrState.Initial( + isLoading = false, + address = "", + ), + ), + ) + } + + @Test + fun `test logout with error shows snackbar`() = runTest { + testRobot + .mockJellyseerrAccountDetailsResponse( + Result.success(JellyseerrAccountDetailsFactory.jellyseerr()), + ) + .mockLogoutJellyseerrResponse(Result.failure(InvalidStatusException(500))) + .buildViewModel() + .assertUiState( + createUiState( + jellyseerrState = JellyseerrState.LoggedIn( + accountDetails = JellyseerrAccountDetailsFactory.jellyseerr(), + isLoading = false, + ), + ), + ) + .onLogoutJellyseerr() + .assertUiState( + createUiState( + snackbarMessage = SnackbarMessage.from( + UIText.ResourceText(uiR.string.core_ui_error_retry), + ), + jellyseerrState = JellyseerrState.LoggedIn( + accountDetails = JellyseerrAccountDetailsFactory.jellyseerr(), + isLoading = false, + ), + ), + ) + } + @Test fun `test dismissSnackbar removes snackbar`() = runTest { testRobot diff --git a/feature/settings/src/test/kotlin/com/divinelink/feature/settings/app/account/jellyseerr/JellyseerrSettingsViewModelTestRobot.kt b/feature/settings/src/test/kotlin/com/divinelink/feature/settings/app/account/jellyseerr/JellyseerrSettingsViewModelTestRobot.kt index 3f11cc10..6ff1a115 100644 --- a/feature/settings/src/test/kotlin/com/divinelink/feature/settings/app/account/jellyseerr/JellyseerrSettingsViewModelTestRobot.kt +++ b/feature/settings/src/test/kotlin/com/divinelink/feature/settings/app/account/jellyseerr/JellyseerrSettingsViewModelTestRobot.kt @@ -29,6 +29,10 @@ class JellyseerrSettingsViewModelTestRobot : ViewModelTestRobot) = apply { + logoutJellyseerrUseCase.mockSuccess(flowOf(response)) + } + fun mockJellyseerrAccountDetailsResponse(response: Result) = apply { getJellyseerrDetailsUseCase.mockSuccess(response) } @@ -53,6 +57,10 @@ class JellyseerrSettingsViewModelTestRobot : ViewModelTestRobot