Skip to content

Commit

Permalink
Merge pull request #73 from Divinelink/fix/jellyseerr-expired-cookie
Browse files Browse the repository at this point in the history
[Jellyseerr] Introduce ReAuth interceptor on Jellyseerr Ktor Client
  • Loading branch information
Divinelink authored Oct 10, 2024
2 parents 7a387c3 + 8b29ad9 commit fbb420f
Show file tree
Hide file tree
Showing 29 changed files with 811 additions and 103 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -65,6 +66,10 @@ class DetailsViewModelRobot {
)
}

fun mockRequestMedia(response: Flow<Result<JellyseerrMediaRequest>>) = apply {
fakeRequestMediaUseCase.mockSuccess(response = response)
}

fun onAddRateClicked() = apply {
viewModel.onAddRateClicked()
}
Expand Down Expand Up @@ -93,6 +98,10 @@ class DetailsViewModelRobot {
viewModel.onMarkAsFavorite()
}

fun onRequestMedia(seasons: List<Int>) = apply {
viewModel.onRequestMedia(seasons)
}

fun consumeSnackbar() = apply {
viewModel.consumeSnackbarMessage()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
),
),
),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ class SessionStorageTest {
)
val encryptedPreferenceStorage = FakeEncryptedPreferenceStorage(
jellyseerrAuthCookie = "123456789qwertyuiop",
jellyseerrPassword = "password",
)

val sessionStorage = SessionStorage(
Expand All @@ -157,5 +158,6 @@ class SessionStorageTest {
assertThat(preferenceStorage.jellyseerrAddress.first()).isNull()
assertThat(preferenceStorage.jellyseerrSignInMethod.first()).isNull()
assertThat(encryptedPreferenceStorage.jellyseerrAuthCookie).isNull()
assertThat(encryptedPreferenceStorage.jellyseerrPassword).isNull()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int, (ErrorHandler) -> Unit>()

val exceptionActions: MutableMap<Class<out Throwable>, (Throwable) -> Unit> = mutableMapOf()

private var otherwiseAction: ((Throwable) -> Unit)? = null
val exceptionActions: MutableMap<Class<out Throwable>, (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 <reified T : Exception> on(noinline action: (Throwable) -> Unit): ErrorHandler =
/**
* Registers an action to be executed when an exception of the specified type occurs.
*/
inline fun <reified T : Throwable> on(noinline action: (Throwable) -> Unit): ErrorHandler =
apply {
exceptionActions[T::class.java] = action
}
Expand All @@ -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)
Expand All @@ -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)? {
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -27,23 +26,13 @@ class ProdJellyseerrRepository(
loginData: JellyseerrLoginData,
): Flow<Result<JellyseerrAccountDetails>> = service
.signInWithJellyfin(loginData)
.map {
Result.success(it.map())
}
.catch { error ->
throw error
}
.map { Result.success(it.map()) }

override suspend fun signInWithJellyseerr(
loginData: JellyseerrLoginData,
): Flow<Result<JellyseerrAccountDetails>> = service
.signInWithJellyseerr(loginData)
.map {
Result.success(it.map())
}
.catch { error ->
throw error
}
.map { Result.success(it.map()) }

override fun getJellyseerrAccountDetails(): Flow<JellyseerrAccountDetails?> = queries
.selectAll()
Expand All @@ -62,21 +51,11 @@ class ProdJellyseerrRepository(
}

override suspend fun logout(address: String): Flow<Result<Unit>> = service.logout(address)
.map {
Result.success(Unit)
}
.catch { error ->
throw error
}
.map { Result.success(Unit) }

override suspend fun requestMedia(
body: JellyseerrRequestMediaBodyApi,
): Flow<Result<JellyseerrMediaRequest>> = service
.requestMedia(body)
.map {
Result.success(it.map())
}
.catch { error ->
throw error
}
.map { Result.success(it.map()) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit fbb420f

Please sign in to comment.